blob: d62eade361a7f74b1ac8d6cc9c168fbc0bde2fc1 [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
Zhuoyao Zhangd28da5c2024-09-24 19:46:12 +000030
Zhuoyao Zhang53359552024-09-16 23:58:11 +000031TEST_BINARY_FILE = '/path/to/test_binary'
32TEST_PID_FILE_PATH = (
33 '587239c2d1050afdf54512e2d799f3b929f86b43575eb3c7b4bab105dd9bd25e.lock'
34)
35
36
Zhuoyao Zhang4d485592024-09-17 21:14:38 +000037def simple_daemon(output_file):
Zhuoyao Zhang53359552024-09-16 23:58:11 +000038 with open(output_file, 'w') as f:
39 f.write('running daemon target')
40
41
42def long_running_daemon():
43 while True:
44 time.sleep(1)
45
46
Zhuoyao Zhangdc2840d2024-09-19 23:29:27 +000047def memory_consume_daemon_target(size_mb):
48 try:
49 size_bytes = size_mb * 1024 * 1024
50 dummy_data = bytearray(size_bytes)
51 time.sleep(10)
52 except MemoryError:
53 print(f'Process failed to allocate {size_mb} MB of memory.')
54
55
56def cpu_consume_daemon_target(target_usage_percent):
57 while True:
58 start_time = time.time()
59 while time.time() - start_time < target_usage_percent / 100:
60 pass # Busy loop to consume CPU
61
62 # Sleep to reduce CPU usage
63 time.sleep(1 - target_usage_percent / 100)
64
65
Zhuoyao Zhang53359552024-09-16 23:58:11 +000066class DaemonManagerTest(unittest.TestCase):
67
68 @classmethod
69 def setUpClass(cls):
70 super().setUpClass()
71 # Configure to print logging to stdout.
72 logging.basicConfig(filename=None, level=logging.DEBUG)
73 console = logging.StreamHandler(sys.stdout)
74 logging.getLogger('').addHandler(console)
75
76 def setUp(self):
77 super().setUp()
78 self.original_tempdir = tempfile.tempdir
79 self.working_dir = tempfile.TemporaryDirectory()
80 # Sets the tempdir under the working dir so any temp files created during
81 # tests will be cleaned.
82 tempfile.tempdir = self.working_dir.name
83
84 def tearDown(self):
85 # Cleans up any child processes left by the tests.
86 self._cleanup_child_processes()
87 self.working_dir.cleanup()
88 # Restores tempdir.
89 tempfile.tempdir = self.original_tempdir
90 super().tearDown()
91
Zhuoyao Zhang4d485592024-09-17 21:14:38 +000092 def test_start_success_with_no_existing_instance(self):
93 self.assert_run_simple_daemon_success()
94
95 def test_start_success_with_existing_instance_running(self):
Zhuoyao Zhangd28da5c2024-09-24 19:46:12 +000096 # Create a running daemon subprocess
97 p = self._create_fake_deamon_process()
Zhuoyao Zhang4d485592024-09-17 21:14:38 +000098
99 self.assert_run_simple_daemon_success()
Zhuoyao Zhang4d485592024-09-17 21:14:38 +0000100
101 def test_start_success_with_existing_instance_already_dead(self):
102 # Create a pidfile with pid that does not exist.
103 pid_file_path_dir = pathlib.Path(self.working_dir.name).joinpath(
104 'edit_monitor'
Zhuoyao Zhang53359552024-09-16 23:58:11 +0000105 )
Zhuoyao Zhang4d485592024-09-17 21:14:38 +0000106 pid_file_path_dir.mkdir(parents=True, exist_ok=True)
107 with open(pid_file_path_dir.joinpath(TEST_PID_FILE_PATH), 'w') as f:
108 f.write('123456')
109
110 self.assert_run_simple_daemon_success()
111
112 def test_start_success_with_existing_instance_from_different_binary(self):
113 # First start an instance based on "some_binary_path"
114 existing_dm = daemon_manager.DaemonManager(
Zhuoyao Zhangdc2840d2024-09-19 23:29:27 +0000115 'some_binary_path',
Zhuoyao Zhang4d485592024-09-17 21:14:38 +0000116 daemon_target=long_running_daemon,
117 )
118 existing_dm.start()
119
120 self.assert_run_simple_daemon_success()
121 existing_dm.stop()
122
Zhuoyao Zhangd28da5c2024-09-24 19:46:12 +0000123 def test_start_return_directly_if_block_sign_exists(self):
124 # Creates the block sign.
125 pathlib.Path(self.working_dir.name).joinpath(
126 daemon_manager.BLOCK_SIGN_FILE
127 ).touch()
128
129 dm = daemon_manager.DaemonManager(TEST_BINARY_FILE)
130 dm.start()
131 # Verify no daemon process is started.
132 self.assertIsNone(dm.daemon_process)
133
Zhuoyao Zhang4d485592024-09-17 21:14:38 +0000134 @mock.patch('os.kill')
135 def test_start_failed_to_kill_existing_instance(self, mock_kill):
136 mock_kill.side_effect = OSError('Unknown OSError')
137 pid_file_path_dir = pathlib.Path(self.working_dir.name).joinpath(
138 'edit_monitor'
139 )
140 pid_file_path_dir.mkdir(parents=True, exist_ok=True)
141 with open(pid_file_path_dir.joinpath(TEST_PID_FILE_PATH), 'w') as f:
142 f.write('123456')
143
Zhuoyao Zhangd28da5c2024-09-24 19:46:12 +0000144 with self.assertRaises(OSError) as error:
145 dm = daemon_manager.DaemonManager(TEST_BINARY_FILE)
146 dm.start()
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
Zhuoyao Zhangd28da5c2024-09-24 19:46:12 +0000156 with self.assertRaises(PermissionError) as error:
157 dm = daemon_manager.DaemonManager(TEST_BINARY_FILE)
158 dm.start()
Zhuoyao Zhang53359552024-09-16 23:58:11 +0000159
160 def test_start_failed_to_start_daemon_process(self):
Zhuoyao Zhangd28da5c2024-09-24 19:46:12 +0000161 with self.assertRaises(TypeError) as error:
162 dm = daemon_manager.DaemonManager(
163 TEST_BINARY_FILE, daemon_target='wrong_target', daemon_args=(1)
164 )
165 dm.start()
Zhuoyao Zhang53359552024-09-16 23:58:11 +0000166
Zhuoyao Zhangdc2840d2024-09-19 23:29:27 +0000167 def test_monitor_daemon_subprocess_killed_high_memory_usage(self):
168 dm = daemon_manager.DaemonManager(
169 TEST_BINARY_FILE,
170 daemon_target=memory_consume_daemon_target,
171 daemon_args=(2,),
172 )
173 dm.start()
174 dm.monitor_daemon(interval=1, memory_threshold=2)
175
176 self.assertTrue(dm.max_memory_usage >= 2)
177 self.assert_no_subprocess_running()
178
179 def test_monitor_daemon_subprocess_killed_high_cpu_usage(self):
180 dm = daemon_manager.DaemonManager(
181 TEST_BINARY_FILE,
182 daemon_target=cpu_consume_daemon_target,
183 daemon_args=(20,),
184 )
185 dm.start()
186 dm.monitor_daemon(interval=1, cpu_threshold=20)
187
188 self.assertTrue(dm.max_cpu_usage >= 20)
189 self.assert_no_subprocess_running()
190
191 @mock.patch('subprocess.check_output')
192 def test_monitor_daemon_failed_does_not_matter(self, mock_output):
193 mock_output.side_effect = OSError('Unknown OSError')
194 self.assert_run_simple_daemon_success()
195
Zhuoyao Zhang205a2fc2024-09-20 18:19:59 +0000196 @mock.patch('os.execv')
197 def test_monitor_daemon_reboot_triggered(self, mock_execv):
198 binary_file = tempfile.NamedTemporaryFile(
199 dir=self.working_dir.name, delete=False
200 )
201
202 dm = daemon_manager.DaemonManager(
203 binary_file.name, daemon_target=long_running_daemon
204 )
205 dm.start()
206 dm.monitor_daemon(reboot_timeout=0.5)
207 mock_execv.assert_called_once()
208
Zhuoyao Zhang53359552024-09-16 23:58:11 +0000209 def test_stop_success(self):
210 dm = daemon_manager.DaemonManager(
211 TEST_BINARY_FILE, daemon_target=long_running_daemon
212 )
213 dm.start()
214 dm.stop()
215
216 self.assert_no_subprocess_running()
217 self.assertFalse(dm.pid_file_path.exists())
218
219 @mock.patch('os.kill')
220 def test_stop_failed_to_kill_daemon_process(self, mock_kill):
221 mock_kill.side_effect = OSError('Unknown OSError')
222 dm = daemon_manager.DaemonManager(
223 TEST_BINARY_FILE, daemon_target=long_running_daemon
224 )
225 dm.start()
226 dm.stop()
227
228 self.assertTrue(dm.daemon_process.is_alive())
229 self.assertTrue(dm.pid_file_path.exists())
230
231 @mock.patch('os.remove')
232 def test_stop_failed_to_remove_pidfile(self, mock_remove):
233 mock_remove.side_effect = OSError('Unknown OSError')
234
235 dm = daemon_manager.DaemonManager(
236 TEST_BINARY_FILE, daemon_target=long_running_daemon
237 )
238 dm.start()
239 dm.stop()
240
241 self.assert_no_subprocess_running()
242 self.assertTrue(dm.pid_file_path.exists())
243
Zhuoyao Zhang205a2fc2024-09-20 18:19:59 +0000244 @mock.patch('os.execv')
245 def test_reboot_success(self, mock_execv):
246 binary_file = tempfile.NamedTemporaryFile(
247 dir=self.working_dir.name, delete=False
248 )
249
250 dm = daemon_manager.DaemonManager(
251 binary_file.name, daemon_target=long_running_daemon
252 )
253 dm.start()
254 dm.reboot()
255
256 # Verifies the old process is stopped
257 self.assert_no_subprocess_running()
258 self.assertFalse(dm.pid_file_path.exists())
259
260 mock_execv.assert_called_once()
261
262 @mock.patch('os.execv')
263 def test_reboot_binary_no_longer_exists(self, mock_execv):
264 dm = daemon_manager.DaemonManager(
265 TEST_BINARY_FILE, daemon_target=long_running_daemon
266 )
267 dm.start()
268
269 with self.assertRaises(SystemExit) as cm:
270 dm.reboot()
271 mock_execv.assert_not_called()
272 self.assertEqual(cm.exception.code, 0)
273
274 @mock.patch('os.execv')
275 def test_reboot_failed(self, mock_execv):
276 mock_execv.side_effect = OSError('Unknown OSError')
277 binary_file = tempfile.NamedTemporaryFile(
278 dir=self.working_dir.name, delete=False
279 )
280
281 dm = daemon_manager.DaemonManager(
282 binary_file.name, daemon_target=long_running_daemon
283 )
284 dm.start()
285
286 with self.assertRaises(SystemExit) as cm:
287 dm.reboot()
288 self.assertEqual(cm.exception.code, 1)
289
Zhuoyao Zhang4d485592024-09-17 21:14:38 +0000290 def assert_run_simple_daemon_success(self):
291 damone_output_file = tempfile.NamedTemporaryFile(
292 dir=self.working_dir.name, delete=False
293 )
294 dm = daemon_manager.DaemonManager(
295 TEST_BINARY_FILE,
296 daemon_target=simple_daemon,
297 daemon_args=(damone_output_file.name,),
298 )
299 dm.start()
Zhuoyao Zhangdc2840d2024-09-19 23:29:27 +0000300 dm.monitor_daemon(interval=1)
Zhuoyao Zhang4d485592024-09-17 21:14:38 +0000301
302 # Verifies the expected pid file is created.
303 expected_pid_file_path = pathlib.Path(self.working_dir.name).joinpath(
304 'edit_monitor', TEST_PID_FILE_PATH
305 )
306 self.assertTrue(expected_pid_file_path.exists())
307
308 # Verify the daemon process is executed successfully.
309 with open(damone_output_file.name, 'r') as f:
310 contents = f.read()
311 self.assertEqual(contents, 'running daemon target')
312
Zhuoyao Zhang53359552024-09-16 23:58:11 +0000313 def assert_no_subprocess_running(self):
314 child_pids = self._get_child_processes(os.getpid())
315 for child_pid in child_pids:
316 self.assertFalse(
317 self._is_process_alive(child_pid), f'process {child_pid} still alive'
318 )
319
Zhuoyao Zhangd28da5c2024-09-24 19:46:12 +0000320 def _get_child_processes(self, parent_pid: int) -> list[int]:
Zhuoyao Zhang53359552024-09-16 23:58:11 +0000321 try:
322 output = subprocess.check_output(
323 ['ps', '-o', 'pid,ppid', '--no-headers'], text=True
324 )
325
326 child_processes = []
327 for line in output.splitlines():
328 pid, ppid = line.split()
329 if int(ppid) == parent_pid:
330 child_processes.append(int(pid))
331 return child_processes
332 except subprocess.CalledProcessError as e:
333 self.fail(f'failed to get child process, error: {e}')
334
Zhuoyao Zhangd28da5c2024-09-24 19:46:12 +0000335 def _is_process_alive(self, pid: int) -> bool:
Zhuoyao Zhang53359552024-09-16 23:58:11 +0000336 try:
337 output = subprocess.check_output(
338 ['ps', '-p', str(pid), '-o', 'state='], text=True
339 ).strip()
340 state = output.split()[0]
341 return state != 'Z' # Check if the state is not 'Z' (zombie)
342 except subprocess.CalledProcessError:
343 return False
344
345 def _cleanup_child_processes(self):
346 child_pids = self._get_child_processes(os.getpid())
347 for child_pid in child_pids:
348 try:
349 os.kill(child_pid, signal.SIGKILL)
350 except ProcessLookupError:
351 # process already terminated
352 pass
353
Zhuoyao Zhangd28da5c2024-09-24 19:46:12 +0000354 def _create_fake_deamon_process(
355 self, name: str = ''
356 ) -> multiprocessing.Process:
357 # Create a long running subprocess
358 p = multiprocessing.Process(target=long_running_daemon)
359 p.start()
360
361 # Create the pidfile with the subprocess pid
362 pid_file_path_dir = pathlib.Path(self.working_dir.name).joinpath(
363 'edit_monitor'
364 )
365 pid_file_path_dir.mkdir(parents=True, exist_ok=True)
366 with open(pid_file_path_dir.joinpath(name + 'pid.lock'), 'w') as f:
367 f.write(str(p.pid))
368 return p
369
Zhuoyao Zhang53359552024-09-16 23:58:11 +0000370
371if __name__ == '__main__':
372 unittest.main()