blob: c09c3213ae4c48f4afd59e3397a3d9c11ed15bf8 [file] [log] [blame]
Zhuoyao Zhang53359552024-09-16 23:58:11 +00001# Copyright 2024, The Android Open Source Project
2#
3# Licensed under the Apache License, Version 2.0 (the "License");
4# you may not use this file except in compliance with the License.
5# You may obtain a copy of the License at
6#
7# http://www.apache.org/licenses/LICENSE-2.0
8#
9# Unless required by applicable law or agreed to in writing, software
10# distributed under the License is distributed on an "AS IS" BASIS,
11# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12# See the License for the specific language governing permissions and
13# limitations under the License.
14
15
16import hashlib
17import logging
18import multiprocessing
19import os
20import pathlib
21import signal
22import subprocess
23import tempfile
24import time
25
26
27DEFAULT_PROCESS_TERMINATION_TIMEOUT_SECONDS = 1
28
29
30def default_daemon_target():
31 """Place holder for the default daemon target."""
32 print("default daemon target")
33
34
35class DaemonManager:
36 """Class to manage and monitor the daemon run as a subprocess."""
37
38 def __init__(
39 self,
40 binary_path: str,
41 daemon_target: callable = default_daemon_target,
42 daemon_args: tuple = (),
43 ):
44 self.binary_path = binary_path
45 self.daemon_target = daemon_target
46 self.daemon_args = daemon_args
47
48 self.pid = os.getpid()
49 self.daemon_process = None
50
51 pid_file_dir = pathlib.Path(tempfile.gettempdir()).joinpath("edit_monitor")
52 pid_file_dir.mkdir(parents=True, exist_ok=True)
53 self.pid_file_path = self._get_pid_file_path(pid_file_dir)
54
55 def start(self):
56 """Writes the pidfile and starts the daemon proces."""
57 try:
58 self._write_pid_to_pidfile()
59 self._start_daemon_process()
60 except Exception as e:
61 logging.exception("Failed to start daemon manager with error %s", e)
62
63 def stop(self):
64 """Stops the daemon process and removes the pidfile."""
65
66 logging.debug("in daemon manager cleanup.")
67 try:
68 if self.daemon_process and self.daemon_process.is_alive():
69 self._terminate_process(self.daemon_process.pid)
70 self._remove_pidfile()
71 except Exception as e:
72 logging.exception("Failed to stop daemon manager with error %s", e)
73
74 def _write_pid_to_pidfile(self):
75 """Creates a pidfile and writes the current pid to the file.
76
77 Raise FileExistsError if the pidfile already exists.
78 """
79 try:
80 # Use the 'x' mode to open the file for exclusive creation
81 with open(self.pid_file_path, "x") as f:
82 f.write(f"{self.pid}")
83 except FileExistsError as e:
84 # This could be caused due to race condition that a user is trying
85 # to start two edit monitors at the same time. Or because there is
86 # already an existing edit monitor running and we can not kill it
87 # for some reason.
88 logging.exception("pidfile %s already exists.", self.pid_file_path)
89 raise e
90
91 def _start_daemon_process(self):
92 """Starts a subprocess to run the daemon."""
93 p = multiprocessing.Process(
94 target=self.daemon_target, args=self.daemon_args
95 )
96 p.start()
97
98 logging.info("Start subprocess with PID %d", p.pid)
99 self.daemon_process = p
100
101 def _terminate_process(
102 self, pid: int, timeout: int = DEFAULT_PROCESS_TERMINATION_TIMEOUT_SECONDS
103 ):
104 """Terminates a process with given pid.
105
106 It first sends a SIGTERM to the process to allow it for proper
107 termination with a timeout. If the process is not terminated within
108 the timeout, kills it forcefully.
109 """
110 try:
111 os.kill(pid, signal.SIGTERM)
112 if not self._wait_for_process_terminate(pid, timeout):
113 logging.warning(
114 "Process %d not terminated within timeout, try force kill", pid
115 )
116 os.kill(pid, signal.SIGKILL)
117 except ProcessLookupError:
118 logging.info("Process with PID %d not found (already terminated)", pid)
119
120 def _wait_for_process_terminate(self, pid: int, timeout: int) -> bool:
121 start_time = time.time()
122
123 while time.time() < start_time + timeout:
124 if not self._is_process_alive(pid):
125 return True
126 time.sleep(1)
127
128 logging.error("Process %d not terminated within %d seconds.", pid, timeout)
129 return False
130
131 def _is_process_alive(self, pid: int) -> bool:
132 try:
133 output = subprocess.check_output(
134 ["ps", "-p", str(pid), "-o", "state="], text=True
135 ).strip()
136 state = output.split()[0]
137 return state != "Z" # Check if the state is not 'Z' (zombie)
138 except subprocess.CalledProcessError:
139 # Process not found (already dead).
140 return False
141 except (FileNotFoundError, OSError, ValueError) as e:
142 logging.warning(
143 "Unable to check the status for process %d with error: %s.", pid, e
144 )
145 return True
146
147 def _remove_pidfile(self):
148 try:
149 os.remove(self.pid_file_path)
150 except FileNotFoundError:
151 logging.info("pid file %s already removed.", self.pid_file_path)
152
153 def _get_pid_file_path(self, pid_file_dir: pathlib.Path) -> pathlib.Path:
154 """Generates the path to store the pidfile.
155
156 The file path should have the format of "/tmp/edit_monitor/xxxx.lock"
157 where xxxx is a hashed value based on the binary path that starts the
158 process.
159 """
160 hash_object = hashlib.sha256()
161 hash_object.update(self.binary_path.encode("utf-8"))
162 pid_file_path = pid_file_dir.joinpath(hash_object.hexdigest() + ".lock")
163 logging.info("pid_file_path: %s", pid_file_path)
164
165 return pid_file_path