blob: 72442c61d45431fec8ce8533f4a5db58eb47d65b [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 Zhang05e28fa2024-10-04 21:58:39 +0000134 def test_start_return_directly_if_in_cog_env(self):
135 dm = daemon_manager.DaemonManager(
136 '/google/cog/cloud/user/workspace/edit_monitor')
137 dm.start()
138 # Verify no daemon process is started.
139 self.assertIsNone(dm.daemon_process)
140
Zhuoyao Zhang4d485592024-09-17 21:14:38 +0000141 @mock.patch('os.kill')
142 def test_start_failed_to_kill_existing_instance(self, mock_kill):
143 mock_kill.side_effect = OSError('Unknown OSError')
144 pid_file_path_dir = pathlib.Path(self.working_dir.name).joinpath(
145 'edit_monitor'
146 )
147 pid_file_path_dir.mkdir(parents=True, exist_ok=True)
148 with open(pid_file_path_dir.joinpath(TEST_PID_FILE_PATH), 'w') as f:
149 f.write('123456')
150
Zhuoyao Zhangd28da5c2024-09-24 19:46:12 +0000151 with self.assertRaises(OSError) as error:
152 dm = daemon_manager.DaemonManager(TEST_BINARY_FILE)
153 dm.start()
Zhuoyao Zhang53359552024-09-16 23:58:11 +0000154
155 def test_start_failed_to_write_pidfile(self):
156 pid_file_path_dir = pathlib.Path(self.working_dir.name).joinpath(
157 'edit_monitor'
158 )
159 pid_file_path_dir.mkdir(parents=True, exist_ok=True)
160 # Makes the directory read-only so write pidfile will fail.
161 os.chmod(pid_file_path_dir, 0o555)
162
Zhuoyao Zhangd28da5c2024-09-24 19:46:12 +0000163 with self.assertRaises(PermissionError) as error:
164 dm = daemon_manager.DaemonManager(TEST_BINARY_FILE)
165 dm.start()
Zhuoyao Zhang53359552024-09-16 23:58:11 +0000166
167 def test_start_failed_to_start_daemon_process(self):
Zhuoyao Zhangd28da5c2024-09-24 19:46:12 +0000168 with self.assertRaises(TypeError) as error:
169 dm = daemon_manager.DaemonManager(
170 TEST_BINARY_FILE, daemon_target='wrong_target', daemon_args=(1)
171 )
172 dm.start()
Zhuoyao Zhang53359552024-09-16 23:58:11 +0000173
Zhuoyao Zhangdc2840d2024-09-19 23:29:27 +0000174 def test_monitor_daemon_subprocess_killed_high_memory_usage(self):
175 dm = daemon_manager.DaemonManager(
176 TEST_BINARY_FILE,
177 daemon_target=memory_consume_daemon_target,
178 daemon_args=(2,),
179 )
180 dm.start()
181 dm.monitor_daemon(interval=1, memory_threshold=2)
182
183 self.assertTrue(dm.max_memory_usage >= 2)
184 self.assert_no_subprocess_running()
185
186 def test_monitor_daemon_subprocess_killed_high_cpu_usage(self):
187 dm = daemon_manager.DaemonManager(
188 TEST_BINARY_FILE,
189 daemon_target=cpu_consume_daemon_target,
190 daemon_args=(20,),
191 )
192 dm.start()
193 dm.monitor_daemon(interval=1, cpu_threshold=20)
194
195 self.assertTrue(dm.max_cpu_usage >= 20)
196 self.assert_no_subprocess_running()
197
198 @mock.patch('subprocess.check_output')
199 def test_monitor_daemon_failed_does_not_matter(self, mock_output):
200 mock_output.side_effect = OSError('Unknown OSError')
201 self.assert_run_simple_daemon_success()
202
Zhuoyao Zhang205a2fc2024-09-20 18:19:59 +0000203 @mock.patch('os.execv')
204 def test_monitor_daemon_reboot_triggered(self, mock_execv):
205 binary_file = tempfile.NamedTemporaryFile(
206 dir=self.working_dir.name, delete=False
207 )
208
209 dm = daemon_manager.DaemonManager(
210 binary_file.name, daemon_target=long_running_daemon
211 )
212 dm.start()
213 dm.monitor_daemon(reboot_timeout=0.5)
214 mock_execv.assert_called_once()
215
Zhuoyao Zhang53359552024-09-16 23:58:11 +0000216 def test_stop_success(self):
217 dm = daemon_manager.DaemonManager(
218 TEST_BINARY_FILE, daemon_target=long_running_daemon
219 )
220 dm.start()
221 dm.stop()
222
223 self.assert_no_subprocess_running()
224 self.assertFalse(dm.pid_file_path.exists())
225
226 @mock.patch('os.kill')
227 def test_stop_failed_to_kill_daemon_process(self, mock_kill):
228 mock_kill.side_effect = OSError('Unknown OSError')
229 dm = daemon_manager.DaemonManager(
230 TEST_BINARY_FILE, daemon_target=long_running_daemon
231 )
232 dm.start()
233 dm.stop()
234
235 self.assertTrue(dm.daemon_process.is_alive())
236 self.assertTrue(dm.pid_file_path.exists())
237
238 @mock.patch('os.remove')
239 def test_stop_failed_to_remove_pidfile(self, mock_remove):
240 mock_remove.side_effect = OSError('Unknown OSError')
241
242 dm = daemon_manager.DaemonManager(
243 TEST_BINARY_FILE, daemon_target=long_running_daemon
244 )
245 dm.start()
246 dm.stop()
247
248 self.assert_no_subprocess_running()
249 self.assertTrue(dm.pid_file_path.exists())
250
Zhuoyao Zhang205a2fc2024-09-20 18:19:59 +0000251 @mock.patch('os.execv')
252 def test_reboot_success(self, mock_execv):
253 binary_file = tempfile.NamedTemporaryFile(
254 dir=self.working_dir.name, delete=False
255 )
256
257 dm = daemon_manager.DaemonManager(
258 binary_file.name, daemon_target=long_running_daemon
259 )
260 dm.start()
261 dm.reboot()
262
263 # Verifies the old process is stopped
264 self.assert_no_subprocess_running()
265 self.assertFalse(dm.pid_file_path.exists())
266
267 mock_execv.assert_called_once()
268
269 @mock.patch('os.execv')
270 def test_reboot_binary_no_longer_exists(self, mock_execv):
271 dm = daemon_manager.DaemonManager(
272 TEST_BINARY_FILE, daemon_target=long_running_daemon
273 )
274 dm.start()
275
276 with self.assertRaises(SystemExit) as cm:
277 dm.reboot()
278 mock_execv.assert_not_called()
279 self.assertEqual(cm.exception.code, 0)
280
281 @mock.patch('os.execv')
282 def test_reboot_failed(self, mock_execv):
283 mock_execv.side_effect = OSError('Unknown OSError')
284 binary_file = tempfile.NamedTemporaryFile(
285 dir=self.working_dir.name, delete=False
286 )
287
288 dm = daemon_manager.DaemonManager(
289 binary_file.name, daemon_target=long_running_daemon
290 )
291 dm.start()
292
293 with self.assertRaises(SystemExit) as cm:
294 dm.reboot()
295 self.assertEqual(cm.exception.code, 1)
296
Zhuoyao Zhang4d485592024-09-17 21:14:38 +0000297 def assert_run_simple_daemon_success(self):
298 damone_output_file = tempfile.NamedTemporaryFile(
299 dir=self.working_dir.name, delete=False
300 )
301 dm = daemon_manager.DaemonManager(
302 TEST_BINARY_FILE,
303 daemon_target=simple_daemon,
304 daemon_args=(damone_output_file.name,),
305 )
306 dm.start()
Zhuoyao Zhangdc2840d2024-09-19 23:29:27 +0000307 dm.monitor_daemon(interval=1)
Zhuoyao Zhang4d485592024-09-17 21:14:38 +0000308
309 # Verifies the expected pid file is created.
310 expected_pid_file_path = pathlib.Path(self.working_dir.name).joinpath(
311 'edit_monitor', TEST_PID_FILE_PATH
312 )
313 self.assertTrue(expected_pid_file_path.exists())
314
315 # Verify the daemon process is executed successfully.
316 with open(damone_output_file.name, 'r') as f:
317 contents = f.read()
318 self.assertEqual(contents, 'running daemon target')
319
Zhuoyao Zhang53359552024-09-16 23:58:11 +0000320 def assert_no_subprocess_running(self):
321 child_pids = self._get_child_processes(os.getpid())
322 for child_pid in child_pids:
323 self.assertFalse(
324 self._is_process_alive(child_pid), f'process {child_pid} still alive'
325 )
326
Zhuoyao Zhangd28da5c2024-09-24 19:46:12 +0000327 def _get_child_processes(self, parent_pid: int) -> list[int]:
Zhuoyao Zhang53359552024-09-16 23:58:11 +0000328 try:
329 output = subprocess.check_output(
330 ['ps', '-o', 'pid,ppid', '--no-headers'], text=True
331 )
332
333 child_processes = []
334 for line in output.splitlines():
335 pid, ppid = line.split()
336 if int(ppid) == parent_pid:
337 child_processes.append(int(pid))
338 return child_processes
339 except subprocess.CalledProcessError as e:
340 self.fail(f'failed to get child process, error: {e}')
341
Zhuoyao Zhangd28da5c2024-09-24 19:46:12 +0000342 def _is_process_alive(self, pid: int) -> bool:
Zhuoyao Zhang53359552024-09-16 23:58:11 +0000343 try:
344 output = subprocess.check_output(
345 ['ps', '-p', str(pid), '-o', 'state='], text=True
346 ).strip()
347 state = output.split()[0]
348 return state != 'Z' # Check if the state is not 'Z' (zombie)
349 except subprocess.CalledProcessError:
350 return False
351
352 def _cleanup_child_processes(self):
353 child_pids = self._get_child_processes(os.getpid())
354 for child_pid in child_pids:
355 try:
356 os.kill(child_pid, signal.SIGKILL)
357 except ProcessLookupError:
358 # process already terminated
359 pass
360
Zhuoyao Zhangd28da5c2024-09-24 19:46:12 +0000361 def _create_fake_deamon_process(
362 self, name: str = ''
363 ) -> multiprocessing.Process:
364 # Create a long running subprocess
365 p = multiprocessing.Process(target=long_running_daemon)
366 p.start()
367
368 # Create the pidfile with the subprocess pid
369 pid_file_path_dir = pathlib.Path(self.working_dir.name).joinpath(
370 'edit_monitor'
371 )
372 pid_file_path_dir.mkdir(parents=True, exist_ok=True)
373 with open(pid_file_path_dir.joinpath(name + 'pid.lock'), 'w') as f:
374 f.write(str(p.pid))
375 return p
376
Zhuoyao Zhang53359552024-09-16 23:58:11 +0000377
378if __name__ == '__main__':
379 unittest.main()