blob: 0c9e04b75748f0c99b245059a3aefeefe21b5a73 [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"""Unittests for DaemonManager."""
16
17import logging
18import multiprocessing
19import os
20import pathlib
21import signal
22import subprocess
23import sys
24import tempfile
25import time
26import unittest
27from unittest import mock
28from edit_monitor import daemon_manager
29
30TEST_BINARY_FILE = '/path/to/test_binary'
31TEST_PID_FILE_PATH = (
32 '587239c2d1050afdf54512e2d799f3b929f86b43575eb3c7b4bab105dd9bd25e.lock'
33)
34
35
Zhuoyao Zhang4d485592024-09-17 21:14:38 +000036def simple_daemon(output_file):
Zhuoyao Zhang53359552024-09-16 23:58:11 +000037 with open(output_file, 'w') as f:
38 f.write('running daemon target')
39
40
41def long_running_daemon():
42 while True:
43 time.sleep(1)
44
45
Zhuoyao Zhangdc2840d2024-09-19 23:29:27 +000046def memory_consume_daemon_target(size_mb):
47 try:
48 size_bytes = size_mb * 1024 * 1024
49 dummy_data = bytearray(size_bytes)
50 time.sleep(10)
51 except MemoryError:
52 print(f'Process failed to allocate {size_mb} MB of memory.')
53
54
55def cpu_consume_daemon_target(target_usage_percent):
56 while True:
57 start_time = time.time()
58 while time.time() - start_time < target_usage_percent / 100:
59 pass # Busy loop to consume CPU
60
61 # Sleep to reduce CPU usage
62 time.sleep(1 - target_usage_percent / 100)
63
64
Zhuoyao Zhang53359552024-09-16 23:58:11 +000065class DaemonManagerTest(unittest.TestCase):
66
67 @classmethod
68 def setUpClass(cls):
69 super().setUpClass()
70 # Configure to print logging to stdout.
71 logging.basicConfig(filename=None, level=logging.DEBUG)
72 console = logging.StreamHandler(sys.stdout)
73 logging.getLogger('').addHandler(console)
74
75 def setUp(self):
76 super().setUp()
77 self.original_tempdir = tempfile.tempdir
78 self.working_dir = tempfile.TemporaryDirectory()
79 # Sets the tempdir under the working dir so any temp files created during
80 # tests will be cleaned.
81 tempfile.tempdir = self.working_dir.name
82
83 def tearDown(self):
84 # Cleans up any child processes left by the tests.
85 self._cleanup_child_processes()
86 self.working_dir.cleanup()
87 # Restores tempdir.
88 tempfile.tempdir = self.original_tempdir
89 super().tearDown()
90
Zhuoyao Zhang4d485592024-09-17 21:14:38 +000091 def test_start_success_with_no_existing_instance(self):
92 self.assert_run_simple_daemon_success()
93
94 def test_start_success_with_existing_instance_running(self):
95 # Create a long running subprocess
96 p = multiprocessing.Process(target=long_running_daemon)
97 p.start()
98
99 # Create a pidfile with the subprocess pid
100 pid_file_path_dir = pathlib.Path(self.working_dir.name).joinpath(
101 'edit_monitor'
Zhuoyao Zhang53359552024-09-16 23:58:11 +0000102 )
Zhuoyao Zhang4d485592024-09-17 21:14:38 +0000103 pid_file_path_dir.mkdir(parents=True, exist_ok=True)
104 with open(pid_file_path_dir.joinpath(TEST_PID_FILE_PATH), 'w') as f:
105 f.write(str(p.pid))
106
107 self.assert_run_simple_daemon_success()
108 p.terminate()
109
110 def test_start_success_with_existing_instance_already_dead(self):
111 # Create a pidfile with pid that does not exist.
112 pid_file_path_dir = pathlib.Path(self.working_dir.name).joinpath(
113 'edit_monitor'
Zhuoyao Zhang53359552024-09-16 23:58:11 +0000114 )
Zhuoyao Zhang4d485592024-09-17 21:14:38 +0000115 pid_file_path_dir.mkdir(parents=True, exist_ok=True)
116 with open(pid_file_path_dir.joinpath(TEST_PID_FILE_PATH), 'w') as f:
117 f.write('123456')
118
119 self.assert_run_simple_daemon_success()
120
121 def test_start_success_with_existing_instance_from_different_binary(self):
122 # First start an instance based on "some_binary_path"
123 existing_dm = daemon_manager.DaemonManager(
Zhuoyao Zhangdc2840d2024-09-19 23:29:27 +0000124 'some_binary_path',
Zhuoyao Zhang4d485592024-09-17 21:14:38 +0000125 daemon_target=long_running_daemon,
126 )
127 existing_dm.start()
128
129 self.assert_run_simple_daemon_success()
130 existing_dm.stop()
131
132 @mock.patch('os.kill')
133 def test_start_failed_to_kill_existing_instance(self, mock_kill):
134 mock_kill.side_effect = OSError('Unknown OSError')
135 pid_file_path_dir = pathlib.Path(self.working_dir.name).joinpath(
136 'edit_monitor'
137 )
138 pid_file_path_dir.mkdir(parents=True, exist_ok=True)
139 with open(pid_file_path_dir.joinpath(TEST_PID_FILE_PATH), 'w') as f:
140 f.write('123456')
141
142 dm = daemon_manager.DaemonManager(TEST_BINARY_FILE)
Zhuoyao Zhang53359552024-09-16 23:58:11 +0000143 dm.start()
Zhuoyao Zhang53359552024-09-16 23:58:11 +0000144
Zhuoyao Zhang4d485592024-09-17 21:14:38 +0000145 # Verify no daemon process is started.
146 self.assertIsNone(dm.daemon_process)
Zhuoyao Zhang53359552024-09-16 23:58:11 +0000147
148 def test_start_failed_to_write_pidfile(self):
149 pid_file_path_dir = pathlib.Path(self.working_dir.name).joinpath(
150 'edit_monitor'
151 )
152 pid_file_path_dir.mkdir(parents=True, exist_ok=True)
153 # Makes the directory read-only so write pidfile will fail.
154 os.chmod(pid_file_path_dir, 0o555)
155
156 dm = daemon_manager.DaemonManager(TEST_BINARY_FILE)
157 dm.start()
158
159 # Verifies no daemon process is started.
160 self.assertIsNone(dm.daemon_process)
161
162 def test_start_failed_to_start_daemon_process(self):
163 dm = daemon_manager.DaemonManager(
164 TEST_BINARY_FILE, daemon_target='wrong_target', daemon_args=(1)
165 )
166 dm.start()
167
168 # Verifies no daemon process is started.
169 self.assertIsNone(dm.daemon_process)
170
Zhuoyao Zhangdc2840d2024-09-19 23:29:27 +0000171 def test_monitor_daemon_subprocess_killed_high_memory_usage(self):
172 dm = daemon_manager.DaemonManager(
173 TEST_BINARY_FILE,
174 daemon_target=memory_consume_daemon_target,
175 daemon_args=(2,),
176 )
177 dm.start()
178 dm.monitor_daemon(interval=1, memory_threshold=2)
179
180 self.assertTrue(dm.max_memory_usage >= 2)
181 self.assert_no_subprocess_running()
182
183 def test_monitor_daemon_subprocess_killed_high_cpu_usage(self):
184 dm = daemon_manager.DaemonManager(
185 TEST_BINARY_FILE,
186 daemon_target=cpu_consume_daemon_target,
187 daemon_args=(20,),
188 )
189 dm.start()
190 dm.monitor_daemon(interval=1, cpu_threshold=20)
191
192 self.assertTrue(dm.max_cpu_usage >= 20)
193 self.assert_no_subprocess_running()
194
195 @mock.patch('subprocess.check_output')
196 def test_monitor_daemon_failed_does_not_matter(self, mock_output):
197 mock_output.side_effect = OSError('Unknown OSError')
198 self.assert_run_simple_daemon_success()
199
Zhuoyao Zhang53359552024-09-16 23:58:11 +0000200 def test_stop_success(self):
201 dm = daemon_manager.DaemonManager(
202 TEST_BINARY_FILE, daemon_target=long_running_daemon
203 )
204 dm.start()
205 dm.stop()
206
207 self.assert_no_subprocess_running()
208 self.assertFalse(dm.pid_file_path.exists())
209
210 @mock.patch('os.kill')
211 def test_stop_failed_to_kill_daemon_process(self, mock_kill):
212 mock_kill.side_effect = OSError('Unknown OSError')
213 dm = daemon_manager.DaemonManager(
214 TEST_BINARY_FILE, daemon_target=long_running_daemon
215 )
216 dm.start()
217 dm.stop()
218
219 self.assertTrue(dm.daemon_process.is_alive())
220 self.assertTrue(dm.pid_file_path.exists())
221
222 @mock.patch('os.remove')
223 def test_stop_failed_to_remove_pidfile(self, mock_remove):
224 mock_remove.side_effect = OSError('Unknown OSError')
225
226 dm = daemon_manager.DaemonManager(
227 TEST_BINARY_FILE, daemon_target=long_running_daemon
228 )
229 dm.start()
230 dm.stop()
231
232 self.assert_no_subprocess_running()
233 self.assertTrue(dm.pid_file_path.exists())
234
Zhuoyao Zhang4d485592024-09-17 21:14:38 +0000235 def assert_run_simple_daemon_success(self):
236 damone_output_file = tempfile.NamedTemporaryFile(
237 dir=self.working_dir.name, delete=False
238 )
239 dm = daemon_manager.DaemonManager(
240 TEST_BINARY_FILE,
241 daemon_target=simple_daemon,
242 daemon_args=(damone_output_file.name,),
243 )
244 dm.start()
Zhuoyao Zhangdc2840d2024-09-19 23:29:27 +0000245 dm.monitor_daemon(interval=1)
Zhuoyao Zhang4d485592024-09-17 21:14:38 +0000246
247 # Verifies the expected pid file is created.
248 expected_pid_file_path = pathlib.Path(self.working_dir.name).joinpath(
249 'edit_monitor', TEST_PID_FILE_PATH
250 )
251 self.assertTrue(expected_pid_file_path.exists())
252
253 # Verify the daemon process is executed successfully.
254 with open(damone_output_file.name, 'r') as f:
255 contents = f.read()
256 self.assertEqual(contents, 'running daemon target')
257
Zhuoyao Zhang53359552024-09-16 23:58:11 +0000258 def assert_no_subprocess_running(self):
259 child_pids = self._get_child_processes(os.getpid())
260 for child_pid in child_pids:
261 self.assertFalse(
262 self._is_process_alive(child_pid), f'process {child_pid} still alive'
263 )
264
265 def _get_child_processes(self, parent_pid):
266 try:
267 output = subprocess.check_output(
268 ['ps', '-o', 'pid,ppid', '--no-headers'], text=True
269 )
270
271 child_processes = []
272 for line in output.splitlines():
273 pid, ppid = line.split()
274 if int(ppid) == parent_pid:
275 child_processes.append(int(pid))
276 return child_processes
277 except subprocess.CalledProcessError as e:
278 self.fail(f'failed to get child process, error: {e}')
279
280 def _is_process_alive(self, pid):
281 try:
282 output = subprocess.check_output(
283 ['ps', '-p', str(pid), '-o', 'state='], text=True
284 ).strip()
285 state = output.split()[0]
286 return state != 'Z' # Check if the state is not 'Z' (zombie)
287 except subprocess.CalledProcessError:
288 return False
289
290 def _cleanup_child_processes(self):
291 child_pids = self._get_child_processes(os.getpid())
292 for child_pid in child_pids:
293 try:
294 os.kill(child_pid, signal.SIGKILL)
295 except ProcessLookupError:
296 # process already terminated
297 pass
298
299
300if __name__ == '__main__':
301 unittest.main()