blob: bcfa85023103de38680b60ae649589c61d2a13ac [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 Zhang205a2fc2024-09-20 18:19:59 +0000200 @mock.patch('os.execv')
201 def test_monitor_daemon_reboot_triggered(self, mock_execv):
202 binary_file = tempfile.NamedTemporaryFile(
203 dir=self.working_dir.name, delete=False
204 )
205
206 dm = daemon_manager.DaemonManager(
207 binary_file.name, daemon_target=long_running_daemon
208 )
209 dm.start()
210 dm.monitor_daemon(reboot_timeout=0.5)
211 mock_execv.assert_called_once()
212
Zhuoyao Zhang53359552024-09-16 23:58:11 +0000213 def test_stop_success(self):
214 dm = daemon_manager.DaemonManager(
215 TEST_BINARY_FILE, daemon_target=long_running_daemon
216 )
217 dm.start()
218 dm.stop()
219
220 self.assert_no_subprocess_running()
221 self.assertFalse(dm.pid_file_path.exists())
222
223 @mock.patch('os.kill')
224 def test_stop_failed_to_kill_daemon_process(self, mock_kill):
225 mock_kill.side_effect = OSError('Unknown OSError')
226 dm = daemon_manager.DaemonManager(
227 TEST_BINARY_FILE, daemon_target=long_running_daemon
228 )
229 dm.start()
230 dm.stop()
231
232 self.assertTrue(dm.daemon_process.is_alive())
233 self.assertTrue(dm.pid_file_path.exists())
234
235 @mock.patch('os.remove')
236 def test_stop_failed_to_remove_pidfile(self, mock_remove):
237 mock_remove.side_effect = OSError('Unknown OSError')
238
239 dm = daemon_manager.DaemonManager(
240 TEST_BINARY_FILE, daemon_target=long_running_daemon
241 )
242 dm.start()
243 dm.stop()
244
245 self.assert_no_subprocess_running()
246 self.assertTrue(dm.pid_file_path.exists())
247
Zhuoyao Zhang205a2fc2024-09-20 18:19:59 +0000248 @mock.patch('os.execv')
249 def test_reboot_success(self, mock_execv):
250 binary_file = tempfile.NamedTemporaryFile(
251 dir=self.working_dir.name, delete=False
252 )
253
254 dm = daemon_manager.DaemonManager(
255 binary_file.name, daemon_target=long_running_daemon
256 )
257 dm.start()
258 dm.reboot()
259
260 # Verifies the old process is stopped
261 self.assert_no_subprocess_running()
262 self.assertFalse(dm.pid_file_path.exists())
263
264 mock_execv.assert_called_once()
265
266 @mock.patch('os.execv')
267 def test_reboot_binary_no_longer_exists(self, mock_execv):
268 dm = daemon_manager.DaemonManager(
269 TEST_BINARY_FILE, daemon_target=long_running_daemon
270 )
271 dm.start()
272
273 with self.assertRaises(SystemExit) as cm:
274 dm.reboot()
275 mock_execv.assert_not_called()
276 self.assertEqual(cm.exception.code, 0)
277
278 @mock.patch('os.execv')
279 def test_reboot_failed(self, mock_execv):
280 mock_execv.side_effect = OSError('Unknown OSError')
281 binary_file = tempfile.NamedTemporaryFile(
282 dir=self.working_dir.name, delete=False
283 )
284
285 dm = daemon_manager.DaemonManager(
286 binary_file.name, daemon_target=long_running_daemon
287 )
288 dm.start()
289
290 with self.assertRaises(SystemExit) as cm:
291 dm.reboot()
292 self.assertEqual(cm.exception.code, 1)
293
Zhuoyao Zhang4d485592024-09-17 21:14:38 +0000294 def assert_run_simple_daemon_success(self):
295 damone_output_file = tempfile.NamedTemporaryFile(
296 dir=self.working_dir.name, delete=False
297 )
298 dm = daemon_manager.DaemonManager(
299 TEST_BINARY_FILE,
300 daemon_target=simple_daemon,
301 daemon_args=(damone_output_file.name,),
302 )
303 dm.start()
Zhuoyao Zhangdc2840d2024-09-19 23:29:27 +0000304 dm.monitor_daemon(interval=1)
Zhuoyao Zhang4d485592024-09-17 21:14:38 +0000305
306 # Verifies the expected pid file is created.
307 expected_pid_file_path = pathlib.Path(self.working_dir.name).joinpath(
308 'edit_monitor', TEST_PID_FILE_PATH
309 )
310 self.assertTrue(expected_pid_file_path.exists())
311
312 # Verify the daemon process is executed successfully.
313 with open(damone_output_file.name, 'r') as f:
314 contents = f.read()
315 self.assertEqual(contents, 'running daemon target')
316
Zhuoyao Zhang53359552024-09-16 23:58:11 +0000317 def assert_no_subprocess_running(self):
318 child_pids = self._get_child_processes(os.getpid())
319 for child_pid in child_pids:
320 self.assertFalse(
321 self._is_process_alive(child_pid), f'process {child_pid} still alive'
322 )
323
324 def _get_child_processes(self, parent_pid):
325 try:
326 output = subprocess.check_output(
327 ['ps', '-o', 'pid,ppid', '--no-headers'], text=True
328 )
329
330 child_processes = []
331 for line in output.splitlines():
332 pid, ppid = line.split()
333 if int(ppid) == parent_pid:
334 child_processes.append(int(pid))
335 return child_processes
336 except subprocess.CalledProcessError as e:
337 self.fail(f'failed to get child process, error: {e}')
338
339 def _is_process_alive(self, pid):
340 try:
341 output = subprocess.check_output(
342 ['ps', '-p', str(pid), '-o', 'state='], text=True
343 ).strip()
344 state = output.split()[0]
345 return state != 'Z' # Check if the state is not 'Z' (zombie)
346 except subprocess.CalledProcessError:
347 return False
348
349 def _cleanup_child_processes(self):
350 child_pids = self._get_child_processes(os.getpid())
351 for child_pid in child_pids:
352 try:
353 os.kill(child_pid, signal.SIGKILL)
354 except ProcessLookupError:
355 # process already terminated
356 pass
357
358
359if __name__ == '__main__':
360 unittest.main()