-
-
Notifications
You must be signed in to change notification settings - Fork 729
/
process.py
399 lines (340 loc) · 14.9 KB
/
process.py
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
import contextlib
import os
import os.path
import shlex
import signal
import subprocess
import sys
import time
import traceback
from .. import __version__
from ..platformflags import is_win32
from ..logger import create_logger
logger = create_logger()
from ..helpers import EXIT_SUCCESS, EXIT_WARNING, EXIT_SIGNAL_BASE, Error
@contextlib.contextmanager
def _daemonize():
from ..platform import get_process_id
old_id = get_process_id()
pid = os.fork()
if pid:
exit_code = EXIT_SUCCESS
try:
yield old_id, None
except _ExitCodeException as e:
exit_code = e.exit_code
finally:
logger.debug("Daemonizing: Foreground process (%s, %s, %s) is now dying." % old_id)
os._exit(exit_code)
os.setsid()
pid = os.fork()
if pid:
os._exit(0)
os.chdir("/")
os.close(0)
os.close(1)
fd = os.open(os.devnull, os.O_RDWR)
os.dup2(fd, 0)
os.dup2(fd, 1)
new_id = get_process_id()
try:
yield old_id, new_id
finally:
# Close / redirect stderr to /dev/null only now
# for the case that we want to log something before yield returns.
os.close(2)
os.dup2(fd, 2)
def daemonize():
"""Detach process from controlling terminal and run in background
Returns: old and new get_process_id tuples
"""
with _daemonize() as (old_id, new_id):
return old_id, new_id
@contextlib.contextmanager
def daemonizing(*, timeout=5):
"""Like daemonize(), but as context manager.
The with-body is executed in the background process,
while the foreground process survives until the body is left
or the given timeout is exceeded. In the latter case a warning is
reported by the foreground.
Context variable is (old_id, new_id) get_process_id tuples.
An exception raised in the body is reported by the foreground
as a warning as well as propagated outside the body in the background.
In case of a warning, the foreground exits with exit code EXIT_WARNING
instead of EXIT_SUCCESS.
"""
with _daemonize() as (old_id, new_id):
if new_id is None:
# The original / parent process, waiting for a signal to die.
logger.debug("Daemonizing: Foreground process (%s, %s, %s) is waiting for background process..." % old_id)
exit_code = EXIT_SUCCESS
# Indeed, SIGHUP and SIGTERM handlers should have been set on archiver.run(). Just in case...
with signal_handler("SIGINT", raising_signal_handler(KeyboardInterrupt)), signal_handler(
"SIGHUP", raising_signal_handler(SigHup)
), signal_handler("SIGTERM", raising_signal_handler(SigTerm)):
try:
if timeout > 0:
time.sleep(timeout)
except SigTerm:
# Normal termination; expected from grandchild, see 'os.kill()' below
pass
except SigHup:
# Background wants to indicate a problem; see 'os.kill()' below,
# log message will come from grandchild.
exit_code = EXIT_WARNING
except KeyboardInterrupt:
# Manual termination.
logger.debug("Daemonizing: Foreground process (%s, %s, %s) received SIGINT." % old_id)
exit_code = EXIT_SIGNAL_BASE + 2
except BaseException as e:
# Just in case...
logger.warning(
"Daemonizing: Foreground process received an exception while waiting:\n"
+ "".join(traceback.format_exception(e.__class__, e, e.__traceback__))
)
exit_code = EXIT_WARNING
else:
logger.warning("Daemonizing: Background process did not respond (timeout). Is it alive?")
exit_code = EXIT_WARNING
finally:
# Don't call with-body, but die immediately!
# return would be sufficient, but we want to pass the exit code.
raise _ExitCodeException(exit_code)
# The background / grandchild process.
sig_to_foreground = signal.SIGTERM
logger.debug("Daemonizing: Background process (%s, %s, %s) is starting..." % new_id)
try:
yield old_id, new_id
except BaseException as e:
sig_to_foreground = signal.SIGHUP
logger.warning(
"Daemonizing: Background process raised an exception while starting:\n"
+ "".join(traceback.format_exception(e.__class__, e, e.__traceback__))
)
raise e
else:
logger.debug("Daemonizing: Background process (%s, %s, %s) has started." % new_id)
finally:
try:
os.kill(old_id[1], sig_to_foreground)
except BaseException as e:
logger.error(
"Daemonizing: Trying to kill the foreground process raised an exception:\n"
+ "".join(traceback.format_exception(e.__class__, e, e.__traceback__))
)
class _ExitCodeException(BaseException):
def __init__(self, exit_code):
self.exit_code = exit_code
class SignalException(BaseException):
"""base class for all signal-based exceptions"""
class SigHup(SignalException):
"""raised on SIGHUP signal"""
class SigTerm(SignalException):
"""raised on SIGTERM signal"""
@contextlib.contextmanager
def signal_handler(sig, handler):
"""
when entering context, set up signal handler <handler> for signal <sig>.
when leaving context, restore original signal handler.
<sig> can bei either a str when giving a signal.SIGXXX attribute name (it
won't crash if the attribute name does not exist as some names are platform
specific) or a int, when giving a signal number.
<handler> is any handler value as accepted by the signal.signal(sig, handler).
"""
if isinstance(sig, str):
sig = getattr(signal, sig, None)
if sig is not None:
orig_handler = signal.signal(sig, handler)
try:
yield
finally:
if sig is not None:
signal.signal(sig, orig_handler)
def raising_signal_handler(exc_cls):
def handler(sig_no, frame):
# setting SIG_IGN avoids that an incoming second signal of this
# kind would raise a 2nd exception while we still process the
# exception handler for exc_cls for the 1st signal.
signal.signal(sig_no, signal.SIG_IGN)
raise exc_cls
return handler
class SigIntManager:
def __init__(self):
self._sig_int_triggered = False
self._action_triggered = False
self._action_done = False
self.ctx = signal_handler("SIGINT", self.handler)
self.debounce_interval = 20000000 # ns
self.last = None # monotonic time when we last processed SIGINT
def __bool__(self):
# this will be True (and stay True) after the first Ctrl-C/SIGINT
return self._sig_int_triggered
def action_triggered(self):
# this is True to indicate that the action shall be done
return self._action_triggered
def action_done(self):
# this will be True after the action has completed
return self._action_done
def action_completed(self):
# this must be called when the action triggered is completed,
# to avoid repeatedly triggering the action.
self._action_triggered = False
self._action_done = True
def handler(self, sig_no, stack):
# Ignore a SIGINT if it comes too quickly after the last one, e.g. because it
# was caused by the same Ctrl-C key press and a parent process forwarded it to us.
# This can easily happen for the pyinstaller-made binaries because the bootloader
# process and the borg process are in same process group (see #8155), but maybe also
# under other circumstances.
now = time.monotonic_ns()
if self.last is None: # first SIGINT
self.last = now
self._sig_int_triggered = True
self._action_triggered = True
elif now - self.last >= self.debounce_interval: # second SIGINT
# restore the original signal handler for the 3rd+ SIGINT -
# this implies that this handler here loses control!
self.__exit__(None, None, None)
# handle 2nd SIGINT like the default handler would do it:
raise KeyboardInterrupt # python docs say this might show up at an arbitrary place.
def __enter__(self):
self.ctx.__enter__()
def __exit__(self, exception_type, exception_value, traceback):
# restore the original ctrl-c handler, so the next ctrl-c / SIGINT does the normal thing:
if self.ctx:
self.ctx.__exit__(exception_type, exception_value, traceback)
self.ctx = None
# global flag which might trigger some special behaviour on first ctrl-c / SIGINT,
# e.g. if this is interrupting "borg create", it shall try to create a checkpoint.
sig_int = SigIntManager()
def ignore_sigint():
"""
Ignore SIGINT, see also issue #6912.
Ctrl-C will send a SIGINT to both the main process (borg) and subprocesses
(e.g. ssh for remote ssh:// repos), but often we do not want the subprocess
getting killed (e.g. because it is still needed to shut down borg cleanly).
To avoid that: Popen(..., preexec_fn=ignore_sigint)
"""
signal.signal(signal.SIGINT, signal.SIG_IGN)
def popen_with_error_handling(cmd_line: str, log_prefix="", **kwargs):
"""
Handle typical errors raised by subprocess.Popen. Return None if an error occurred,
otherwise return the Popen object.
*cmd_line* is split using shlex (e.g. 'gzip -9' => ['gzip', '-9']).
Log messages will be prefixed with *log_prefix*; if set, it should end with a space
(e.g. log_prefix='--some-option: ').
Does not change the exit code.
"""
assert not kwargs.get("shell"), "Sorry pal, shell mode is a no-no"
try:
command = shlex.split(cmd_line)
if not command:
raise ValueError("an empty command line is not permitted")
except ValueError as ve:
logger.error("%s%s", log_prefix, ve)
return
logger.debug("%scommand line: %s", log_prefix, command)
try:
return subprocess.Popen(command, **kwargs)
except FileNotFoundError:
logger.error("%sexecutable not found: %s", log_prefix, command[0])
return
except PermissionError:
logger.error("%spermission denied: %s", log_prefix, command[0])
return
def is_terminal(fd=sys.stdout):
return hasattr(fd, "isatty") and fd.isatty() and (not is_win32 or "ANSICON" in os.environ)
def prepare_subprocess_env(system, env=None):
"""
Prepare the environment for a subprocess we are going to create.
:param system: True for preparing to invoke system-installed binaries,
False for stuff inside the pyinstaller environment (like borg, python).
:param env: optionally give a environment dict here. if not given, default to os.environ.
:return: a modified copy of the environment
"""
env = dict(env if env is not None else os.environ)
if system:
# a pyinstaller binary's bootloader modifies LD_LIBRARY_PATH=/tmp/_MEIXXXXXX,
# but we do not want that system binaries (like ssh or other) pick up
# (non-matching) libraries from there.
# thus we install the original LDLP, before pyinstaller has modified it:
lp_key = "LD_LIBRARY_PATH"
lp_orig = env.get(lp_key + "_ORIG") # pyinstaller >= 20160820 / v3.2.1 has this
if lp_orig is not None:
env[lp_key] = lp_orig
else:
# We get here in 2 cases:
# 1. when not running a pyinstaller-made binary.
# in this case, we must not kill LDLP.
# 2. when running a pyinstaller-made binary and there was no LDLP
# in the original env (in this case, the pyinstaller bootloader
# does *not* put ..._ORIG into the env either).
# in this case, we must kill LDLP.
# We can recognize this via sys.frozen and sys._MEIPASS being set.
lp = env.get(lp_key)
if lp is not None and getattr(sys, "frozen", False) and hasattr(sys, "_MEIPASS"):
env.pop(lp_key)
# security: do not give secrets to subprocess
env.pop("BORG_PASSPHRASE", None)
# for information, give borg version to the subprocess
env["BORG_VERSION"] = __version__
return env
@contextlib.contextmanager
def create_filter_process(cmd, stream, stream_close, inbound=True):
if cmd:
# put a filter process between stream and us (e.g. a [de]compression command)
# inbound: <stream> --> filter --> us
# outbound: us --> filter --> <stream>
filter_stream = stream
filter_stream_close = stream_close
env = prepare_subprocess_env(system=True)
# There is no deadlock potential here (the subprocess docs warn about this), because
# communication with the process is a one-way road, i.e. the process can never block
# for us to do something while we block on the process for something different.
if inbound:
proc = popen_with_error_handling(
cmd,
stdout=subprocess.PIPE,
stdin=filter_stream,
log_prefix="filter-process: ",
env=env,
preexec_fn=None if is_win32 else ignore_sigint,
)
else:
proc = popen_with_error_handling(
cmd,
stdin=subprocess.PIPE,
stdout=filter_stream,
log_prefix="filter-process: ",
env=env,
preexec_fn=None if is_win32 else ignore_sigint,
)
if not proc:
raise Error(f"filter {cmd}: process creation failed")
stream = proc.stdout if inbound else proc.stdin
# inbound: do not close the pipe (this is the task of the filter process [== writer])
# outbound: close the pipe, otherwise the filter process would not notice when we are done.
stream_close = not inbound
try:
yield stream
except Exception:
# something went wrong with processing the stream by borg
logger.debug("Exception, killing the filter...")
if cmd:
proc.kill()
borg_succeeded = False
raise
else:
borg_succeeded = True
finally:
if stream_close:
stream.close()
if cmd:
logger.debug("Done, waiting for filter to die...")
rc = proc.wait()
logger.debug("filter cmd exited with code %d", rc)
if filter_stream_close:
filter_stream.close()
if borg_succeeded and rc:
# if borg did not succeed, we know that we killed the filter process
raise Error("filter %s failed, rc=%d" % (cmd, rc))