]> glassweightruler.freedombox.rocks Git - waydroid.git/blob - tools/helpers/run_core.py
tools: Add restart container
[waydroid.git] / tools / helpers / run_core.py
1 # Copyright 2021 Oliver Smith
2 # SPDX-License-Identifier: GPL-3.0-or-later
3 import fcntl
4 import logging
5 import selectors
6 import subprocess
7 import sys
8 import threading
9 import time
10 import os
11 import tools.helpers.run
12
13 """ For a detailed description of all output modes, read the description of
14 core() at the bottom. All other functions in this file get (indirectly)
15 called by core(). """
16
17
18 def sanity_checks(output="log", output_return=False, check=None):
19 """
20 Raise an exception if the parameters passed to core() don't make sense
21 (all parameters are described in core() below).
22 """
23 vals = ["log", "stdout", "interactive", "tui", "background", "pipe"]
24 if output not in vals:
25 raise RuntimeError("Invalid output value: " + str(output))
26
27 # Prevent setting the check parameter with output="background".
28 # The exit code won't be checked when running in background, so it would
29 # always by check=False. But we prevent it from getting set to check=False
30 # as well, so it does not look like you could change it to check=True.
31 if check is not None and output == "background":
32 raise RuntimeError("Can't use check with output: background")
33
34 if output_return and output in ["tui", "background"]:
35 raise RuntimeError("Can't use output_return with output: " + output)
36
37
38 def background(args, cmd, working_dir=None):
39 """ Run a subprocess in background and redirect its output to the log. """
40 ret = subprocess.Popen(cmd, stdout=args.logfd, stderr=args.logfd,
41 cwd=working_dir)
42 logging.debug("New background process: pid={}, output=background".format(ret.pid))
43 return ret
44
45
46 def pipe(args, cmd, working_dir=None):
47 """ Run a subprocess in background and redirect its output to a pipe. """
48 ret = subprocess.Popen(cmd, stdout=subprocess.PIPE, stderr=args.logfd,
49 cwd=working_dir)
50 logging.verbose("New background process: pid={}, output=pipe".format(ret.pid))
51 return ret
52
53
54 def pipe_read(args, process, output_to_stdout=False, output_return=False,
55 output_return_buffer=False):
56 """
57 Read all available output from a subprocess and copy it to the log and
58 optionally stdout and a buffer variable. This is only meant to be called by
59 foreground_pipe() below.
60
61 :param process: subprocess.Popen instance
62 :param output_to_stdout: copy all output to waydroid's stdout
63 :param output_return: when set to True, output_return_buffer will be
64 extended
65 :param output_return_buffer: list of bytes that gets extended with the
66 current output in case output_return is True.
67 """
68 while True:
69 # Copy available output
70 out = process.stdout.readline()
71 if len(out):
72 args.logfd.buffer.write(out)
73 if output_to_stdout:
74 sys.stdout.buffer.write(out)
75 if output_return:
76 output_return_buffer.append(out)
77 continue
78
79 # No more output (flush buffers)
80 args.logfd.flush()
81 if output_to_stdout:
82 sys.stdout.flush()
83 return
84
85
86 def kill_process_tree(args, pid, ppids, sudo):
87 """
88 Recursively kill a pid and its child processes
89
90 :param pid: process id that will be killed
91 :param ppids: list of process id and parent process id tuples (pid, ppid)
92 :param sudo: use sudo to kill the process
93 """
94 if sudo:
95 tools.helpers.run.root(args, ["kill", "-9", str(pid)],
96 check=False)
97 else:
98 tools.helpers.run.user(args, ["kill", "-9", str(pid)],
99 check=False)
100
101 for (child_pid, child_ppid) in ppids:
102 if child_ppid == str(pid):
103 kill_process_tree(args, child_pid, ppids, sudo)
104
105
106 def kill_command(args, pid, sudo):
107 """
108 Kill a command process and recursively kill its child processes
109
110 :param pid: process id that will be killed
111 :param sudo: use sudo to kill the process
112 """
113 cmd = ["ps", "-e", "-o", "pid,ppid"]
114 ret = subprocess.run(cmd, check=True, stdout=subprocess.PIPE)
115 ppids = []
116 proc_entries = ret.stdout.decode("utf-8").rstrip().split('\n')[1:]
117 for row in proc_entries:
118 items = row.split()
119 if len(items) != 2:
120 raise RuntimeError("Unexpected ps output: " + row)
121 ppids.append(items)
122
123 kill_process_tree(args, pid, ppids, sudo)
124
125
126 def foreground_pipe(args, cmd, working_dir=None, output_to_stdout=False,
127 output_return=False, output_timeout=True,
128 sudo=False):
129 """
130 Run a subprocess in foreground with redirected output and optionally kill
131 it after being silent for too long.
132
133 :param cmd: command as list, e.g. ["echo", "string with spaces"]
134 :param working_dir: path in host system where the command should run
135 :param output_to_stdout: copy all output to waydroid's stdout
136 :param output_return: return the output of the whole program
137 :param output_timeout: kill the process when it doesn't print any output
138 after a certain time (configured with --timeout)
139 and raise a RuntimeError exception
140 :param sudo: use sudo to kill the process when it hits the timeout
141 :returns: (code, output)
142 * code: return code of the program
143 * output: ""
144 * output: full program output string (output_return is True)
145 """
146 # Start process in background (stdout and stderr combined)
147 process = subprocess.Popen(cmd, stdout=subprocess.PIPE,
148 stderr=subprocess.STDOUT, cwd=working_dir)
149
150 # Make process.stdout non-blocking
151 handle = process.stdout.fileno()
152 flags = fcntl.fcntl(handle, fcntl.F_GETFL)
153 fcntl.fcntl(handle, fcntl.F_SETFL, flags | os.O_NONBLOCK)
154
155 # While process exists wait for output (with timeout)
156 output_buffer = []
157 sel = selectors.DefaultSelector()
158 sel.register(process.stdout, selectors.EVENT_READ)
159 timeout = args.timeout if output_timeout else None
160 while process.poll() is None:
161 wait_start = time.perf_counter() if output_timeout else None
162 sel.select(timeout)
163
164 # On timeout raise error (we need to measure time on our own, because
165 # select() may exit early even if there is no data to read and the
166 # timeout was not reached.)
167 if output_timeout:
168 wait_end = time.perf_counter()
169 if wait_end - wait_start >= args.timeout:
170 logging.info("Process did not write any output for " +
171 str(args.timeout) + " seconds. Killing it.")
172 logging.info("NOTE: The timeout can be increased with"
173 " 'waydroid -t'.")
174 kill_command(args, process.pid, sudo)
175 continue
176
177 # Read all currently available output
178 pipe_read(args, process, output_to_stdout, output_return,
179 output_buffer)
180
181 # There may still be output after the process quit
182 pipe_read(args, process, output_to_stdout, output_return, output_buffer)
183
184 # Return the return code and output (the output gets built as list of
185 # output chunks and combined at the end, this is faster than extending the
186 # combined string with each new chunk)
187 return (process.returncode, b"".join(output_buffer).decode("utf-8"))
188
189
190 def foreground_tui(cmd, working_dir=None):
191 """
192 Run a subprocess in foreground without redirecting any of its output.
193
194 This is the only way text-based user interfaces (ncurses programs like
195 vim, nano or the kernel's menuconfig) work properly.
196 """
197
198 logging.debug("*** output passed to waydroid stdout, not to this log"
199 " ***")
200 process = subprocess.Popen(cmd, cwd=working_dir)
201 return process.wait()
202
203
204 def check_return_code(args, code, log_message):
205 """
206 Check the return code of a command.
207
208 :param code: exit code to check
209 :param log_message: simplified and more readable form of the command, e.g.
210 "(native) % echo test" instead of the full command with
211 entering the chroot and more escaping
212 :raises RuntimeError: when the code indicates that the command failed
213 """
214
215 if code:
216 logging.debug("^" * 70)
217 logging.info("NOTE: The failed command's output is above the ^^^ line"
218 " in the log file: " + args.log)
219 raise RuntimeError("Command failed: " + log_message)
220
221
222 def sudo_timer_iterate():
223 """
224 Run sudo -v and schedule a new timer to repeat the same.
225 """
226
227 subprocess.Popen(["sudo", "-v"]).wait()
228
229 timer = threading.Timer(interval=60, function=sudo_timer_iterate)
230 timer.daemon = True
231 timer.start()
232
233
234 def sudo_timer_start(args):
235 """
236 Start a timer to call sudo -v periodically, so that the password is only
237 needed once.
238 """
239
240 if "sudo_timer_active" in args.cache:
241 return
242 args.cache["sudo_timer_active"] = True
243
244 sudo_timer_iterate()
245
246
247 def core(args, log_message, cmd, working_dir=None, output="log",
248 output_return=False, check=None, sudo=False, disable_timeout=False):
249 """
250 Run a command and create a log entry.
251
252 This is a low level function not meant to be used directly. Use one of the
253 following instead: tools.helpers.run.user(), tools.helpers.run.root(),
254 tools.chroot.user(), tools.chroot.root()
255
256 :param log_message: simplified and more readable form of the command, e.g.
257 "(native) % echo test" instead of the full command with
258 entering the chroot and more escaping
259 :param cmd: command as list, e.g. ["echo", "string with spaces"]
260 :param working_dir: path in host system where the command should run
261 :param output: where to write the output (stdout and stderr) of the
262 process. We almost always write to the log file, which can
263 be read with "waydroid log" (output values: "log",
264 "stdout", "interactive", "background"), so it's easy to
265 trace what waydroid does.
266
267 The exceptions are "tui" (text-based user interface), where
268 it does not make sense to write to the log file (think of
269 ncurses UIs, such as "menuconfig") and "pipe" where the
270 output is written to a pipe for manual asynchronous
271 consumption by the caller.
272
273 When the output is not set to "interactive", "tui",
274 "background" or "pipe", we kill the process if it does not
275 output anything for 5 minutes (time can be set with
276 "waydroid --timeout").
277
278 The table below shows all possible values along with
279 their properties. "wait" indicates that we wait for the
280 process to complete.
281
282 output value | timeout | out to log | out to stdout | wait
283 -----------------------------------------------------------
284 "log" | x | x | | x
285 "stdout" | x | x | x | x
286 "interactive" | | x | x | x
287 "tui" | | | x | x
288 "background" | | x | |
289 "pipe" | | | |
290
291 :param output_return: in addition to writing the program's output to the
292 destinations above in real time, write to a buffer
293 and return it as string when the command has
294 completed. This is not possible when output is
295 "background", "pipe" or "tui".
296 :param check: an exception will be raised when the command's return code
297 is not 0. Set this to False to disable the check. This
298 parameter can not be used when the output is "background" or
299 "pipe".
300 :param sudo: use sudo to kill the process when it hits the timeout.
301 :returns: * program's return code (default)
302 * subprocess.Popen instance (output is "background" or "pipe")
303 * the program's entire output (output_return is True)
304 """
305 sanity_checks(output, output_return, check)
306
307 if args.sudo_timer and sudo:
308 sudo_timer_start(args)
309
310 # Log simplified and full command (waydroid -v)
311 logging.debug(log_message)
312 logging.verbose("run: " + str(cmd))
313
314 # Background
315 if output == "background":
316 return background(args, cmd, working_dir)
317
318 # Pipe
319 if output == "pipe":
320 return pipe(args, cmd, working_dir)
321
322 # Foreground
323 output_after_run = ""
324 if output == "tui":
325 # Foreground TUI
326 code = foreground_tui(cmd, working_dir)
327 else:
328 # Foreground pipe (always redirects to the error log file)
329 output_to_stdout = False
330 if not args.details_to_stdout and output in ["stdout", "interactive"]:
331 output_to_stdout = True
332
333 output_timeout = output in ["log", "stdout"] and not disable_timeout
334
335 (code, output_after_run) = foreground_pipe(args, cmd, working_dir,
336 output_to_stdout,
337 output_return,
338 output_timeout,
339 sudo)
340
341 # Check the return code
342 if check is not False:
343 check_return_code(args, code, log_message)
344
345 # Return (code or output string)
346 return output_after_run if output_return else code