blob: 12aaab3f804eb582910fa91454b37d5a09e85b9b [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
Zhuoyao Zhangba64f312024-10-14 20:32:53 +000029from proto import edit_event_pb2
Zhuoyao Zhang53359552024-09-16 23:58:11 +000030
Zhuoyao Zhangd28da5c2024-09-24 19:46:12 +000031
Zhuoyao Zhang53359552024-09-16 23:58:11 +000032TEST_BINARY_FILE = '/path/to/test_binary'
33TEST_PID_FILE_PATH = (
34 '587239c2d1050afdf54512e2d799f3b929f86b43575eb3c7b4bab105dd9bd25e.lock'
35)
36
37
Zhuoyao Zhang4d485592024-09-17 21:14:38 +000038def simple_daemon(output_file):
Zhuoyao Zhang53359552024-09-16 23:58:11 +000039 with open(output_file, 'w') as f:
40 f.write('running daemon target')
41
42
43def long_running_daemon():
44 while True:
45 time.sleep(1)
46
47
Zhuoyao Zhangdc2840d2024-09-19 23:29:27 +000048def memory_consume_daemon_target(size_mb):
49 try:
50 size_bytes = size_mb * 1024 * 1024
51 dummy_data = bytearray(size_bytes)
52 time.sleep(10)
53 except MemoryError:
54 print(f'Process failed to allocate {size_mb} MB of memory.')
55
56
57def cpu_consume_daemon_target(target_usage_percent):
58 while True:
59 start_time = time.time()
60 while time.time() - start_time < target_usage_percent / 100:
61 pass # Busy loop to consume CPU
62
63 # Sleep to reduce CPU usage
64 time.sleep(1 - target_usage_percent / 100)
65
66
Zhuoyao Zhang53359552024-09-16 23:58:11 +000067class DaemonManagerTest(unittest.TestCase):
68
69 @classmethod
70 def setUpClass(cls):
71 super().setUpClass()
72 # Configure to print logging to stdout.
73 logging.basicConfig(filename=None, level=logging.DEBUG)
74 console = logging.StreamHandler(sys.stdout)
75 logging.getLogger('').addHandler(console)
76
77 def setUp(self):
78 super().setUp()
79 self.original_tempdir = tempfile.tempdir
80 self.working_dir = tempfile.TemporaryDirectory()
81 # Sets the tempdir under the working dir so any temp files created during
82 # tests will be cleaned.
83 tempfile.tempdir = self.working_dir.name
Zhuoyao Zhangd1c4a8b2024-11-06 21:48:45 +000084 self.patch = mock.patch.dict(
85 os.environ, {'ENABLE_ANDROID_EDIT_MONITOR': 'true'})
Zhuoyao Zhang3ca7cef2024-10-31 22:07:31 +000086 self.patch.start()
Zhuoyao Zhang53359552024-09-16 23:58:11 +000087
88 def tearDown(self):
89 # Cleans up any child processes left by the tests.
90 self._cleanup_child_processes()
91 self.working_dir.cleanup()
92 # Restores tempdir.
93 tempfile.tempdir = self.original_tempdir
Zhuoyao Zhang3ca7cef2024-10-31 22:07:31 +000094 self.patch.stop()
Zhuoyao Zhang53359552024-09-16 23:58:11 +000095 super().tearDown()
96
Zhuoyao Zhang4d485592024-09-17 21:14:38 +000097 def test_start_success_with_no_existing_instance(self):
98 self.assert_run_simple_daemon_success()
99
100 def test_start_success_with_existing_instance_running(self):
Zhuoyao Zhangd28da5c2024-09-24 19:46:12 +0000101 # Create a running daemon subprocess
102 p = self._create_fake_deamon_process()
Zhuoyao Zhang4d485592024-09-17 21:14:38 +0000103
104 self.assert_run_simple_daemon_success()
Zhuoyao Zhang4d485592024-09-17 21:14:38 +0000105
106 def test_start_success_with_existing_instance_already_dead(self):
107 # Create a pidfile with pid that does not exist.
108 pid_file_path_dir = pathlib.Path(self.working_dir.name).joinpath(
109 'edit_monitor'
Zhuoyao Zhang53359552024-09-16 23:58:11 +0000110 )
Zhuoyao Zhang4d485592024-09-17 21:14:38 +0000111 pid_file_path_dir.mkdir(parents=True, exist_ok=True)
112 with open(pid_file_path_dir.joinpath(TEST_PID_FILE_PATH), 'w') as f:
113 f.write('123456')
114
115 self.assert_run_simple_daemon_success()
116
117 def test_start_success_with_existing_instance_from_different_binary(self):
118 # First start an instance based on "some_binary_path"
119 existing_dm = daemon_manager.DaemonManager(
Zhuoyao Zhangdc2840d2024-09-19 23:29:27 +0000120 'some_binary_path',
Zhuoyao Zhang4d485592024-09-17 21:14:38 +0000121 daemon_target=long_running_daemon,
122 )
123 existing_dm.start()
124
125 self.assert_run_simple_daemon_success()
126 existing_dm.stop()
127
Zhuoyao Zhangd28da5c2024-09-24 19:46:12 +0000128 def test_start_return_directly_if_block_sign_exists(self):
129 # Creates the block sign.
130 pathlib.Path(self.working_dir.name).joinpath(
131 daemon_manager.BLOCK_SIGN_FILE
132 ).touch()
133
134 dm = daemon_manager.DaemonManager(TEST_BINARY_FILE)
135 dm.start()
Zhuoyao Zhang3ca7cef2024-10-31 22:07:31 +0000136
137 # Verify no daemon process is started.
138 self.assertIsNone(dm.daemon_process)
139
Zhuoyao Zhangd1c4a8b2024-11-06 21:48:45 +0000140 @mock.patch.dict(os.environ, {'ENABLE_ANDROID_EDIT_MONITOR': 'false'}, clear=True)
Zhuoyao Zhang3ca7cef2024-10-31 22:07:31 +0000141 def test_start_return_directly_if_disabled(self):
142 dm = daemon_manager.DaemonManager(TEST_BINARY_FILE)
143 dm.start()
144
Zhuoyao Zhangd28da5c2024-09-24 19:46:12 +0000145 # Verify no daemon process is started.
146 self.assertIsNone(dm.daemon_process)
147
Zhuoyao Zhang05e28fa2024-10-04 21:58:39 +0000148 def test_start_return_directly_if_in_cog_env(self):
149 dm = daemon_manager.DaemonManager(
Zhuoyao Zhangba64f312024-10-14 20:32:53 +0000150 '/google/cog/cloud/user/workspace/edit_monitor'
151 )
Zhuoyao Zhang05e28fa2024-10-04 21:58:39 +0000152 dm.start()
Zhuoyao Zhang3ca7cef2024-10-31 22:07:31 +0000153
Zhuoyao Zhang05e28fa2024-10-04 21:58:39 +0000154 # Verify no daemon process is started.
155 self.assertIsNone(dm.daemon_process)
156
Zhuoyao Zhang4d485592024-09-17 21:14:38 +0000157 @mock.patch('os.kill')
158 def test_start_failed_to_kill_existing_instance(self, mock_kill):
159 mock_kill.side_effect = OSError('Unknown OSError')
160 pid_file_path_dir = pathlib.Path(self.working_dir.name).joinpath(
161 'edit_monitor'
162 )
163 pid_file_path_dir.mkdir(parents=True, exist_ok=True)
164 with open(pid_file_path_dir.joinpath(TEST_PID_FILE_PATH), 'w') as f:
165 f.write('123456')
166
Zhuoyao Zhangba64f312024-10-14 20:32:53 +0000167 fake_cclient = FakeClearcutClient()
168 with self.assertRaises(OSError):
169 dm = daemon_manager.DaemonManager(TEST_BINARY_FILE, cclient=fake_cclient)
Zhuoyao Zhangd28da5c2024-09-24 19:46:12 +0000170 dm.start()
Zhuoyao Zhangba64f312024-10-14 20:32:53 +0000171 self._assert_error_event_logged(
172 fake_cclient, edit_event_pb2.EditEvent.FAILED_TO_START_EDIT_MONITOR
173 )
Zhuoyao Zhang53359552024-09-16 23:58:11 +0000174
175 def test_start_failed_to_write_pidfile(self):
176 pid_file_path_dir = pathlib.Path(self.working_dir.name).joinpath(
177 'edit_monitor'
178 )
179 pid_file_path_dir.mkdir(parents=True, exist_ok=True)
180 # Makes the directory read-only so write pidfile will fail.
181 os.chmod(pid_file_path_dir, 0o555)
182
Zhuoyao Zhangba64f312024-10-14 20:32:53 +0000183 fake_cclient = FakeClearcutClient()
184 with self.assertRaises(PermissionError):
185 dm = daemon_manager.DaemonManager(TEST_BINARY_FILE, cclient=fake_cclient)
Zhuoyao Zhangd28da5c2024-09-24 19:46:12 +0000186 dm.start()
Zhuoyao Zhangba64f312024-10-14 20:32:53 +0000187 self._assert_error_event_logged(
188 fake_cclient, edit_event_pb2.EditEvent.FAILED_TO_START_EDIT_MONITOR
189 )
Zhuoyao Zhang53359552024-09-16 23:58:11 +0000190
191 def test_start_failed_to_start_daemon_process(self):
Zhuoyao Zhangba64f312024-10-14 20:32:53 +0000192 fake_cclient = FakeClearcutClient()
193 with self.assertRaises(TypeError):
Zhuoyao Zhangd28da5c2024-09-24 19:46:12 +0000194 dm = daemon_manager.DaemonManager(
Zhuoyao Zhangba64f312024-10-14 20:32:53 +0000195 TEST_BINARY_FILE,
196 daemon_target='wrong_target',
197 daemon_args=(1),
198 cclient=fake_cclient,
Zhuoyao Zhangd28da5c2024-09-24 19:46:12 +0000199 )
200 dm.start()
Zhuoyao Zhangba64f312024-10-14 20:32:53 +0000201 self._assert_error_event_logged(
202 fake_cclient, edit_event_pb2.EditEvent.FAILED_TO_START_EDIT_MONITOR
203 )
Zhuoyao Zhang53359552024-09-16 23:58:11 +0000204
Zhuoyao Zhangdc2840d2024-09-19 23:29:27 +0000205 def test_monitor_daemon_subprocess_killed_high_memory_usage(self):
Zhuoyao Zhangba64f312024-10-14 20:32:53 +0000206 fake_cclient = FakeClearcutClient()
Zhuoyao Zhangdc2840d2024-09-19 23:29:27 +0000207 dm = daemon_manager.DaemonManager(
208 TEST_BINARY_FILE,
209 daemon_target=memory_consume_daemon_target,
210 daemon_args=(2,),
Zhuoyao Zhangba64f312024-10-14 20:32:53 +0000211 cclient=fake_cclient,
Zhuoyao Zhangdc2840d2024-09-19 23:29:27 +0000212 )
213 dm.start()
214 dm.monitor_daemon(interval=1, memory_threshold=2)
215
216 self.assertTrue(dm.max_memory_usage >= 2)
217 self.assert_no_subprocess_running()
Zhuoyao Zhangba64f312024-10-14 20:32:53 +0000218 self._assert_error_event_logged(
219 fake_cclient,
Zhuoyao Zhang585b4342024-11-12 22:46:43 +0000220 edit_event_pb2.EditEvent.KILLED_DUE_TO_EXCEEDED_MEMORY_USAGE,
Zhuoyao Zhangba64f312024-10-14 20:32:53 +0000221 )
Zhuoyao Zhangdc2840d2024-09-19 23:29:27 +0000222
223 def test_monitor_daemon_subprocess_killed_high_cpu_usage(self):
Zhuoyao Zhangba64f312024-10-14 20:32:53 +0000224 fake_cclient = FakeClearcutClient()
Zhuoyao Zhangdc2840d2024-09-19 23:29:27 +0000225 dm = daemon_manager.DaemonManager(
226 TEST_BINARY_FILE,
227 daemon_target=cpu_consume_daemon_target,
228 daemon_args=(20,),
Zhuoyao Zhangba64f312024-10-14 20:32:53 +0000229 cclient=fake_cclient,
Zhuoyao Zhangdc2840d2024-09-19 23:29:27 +0000230 )
231 dm.start()
232 dm.monitor_daemon(interval=1, cpu_threshold=20)
233
234 self.assertTrue(dm.max_cpu_usage >= 20)
235 self.assert_no_subprocess_running()
Zhuoyao Zhangba64f312024-10-14 20:32:53 +0000236 self._assert_error_event_logged(
237 fake_cclient,
Zhuoyao Zhang585b4342024-11-12 22:46:43 +0000238 edit_event_pb2.EditEvent.KILLED_DUE_TO_EXCEEDED_CPU_USAGE,
Zhuoyao Zhangba64f312024-10-14 20:32:53 +0000239 )
Zhuoyao Zhangdc2840d2024-09-19 23:29:27 +0000240
241 @mock.patch('subprocess.check_output')
242 def test_monitor_daemon_failed_does_not_matter(self, mock_output):
243 mock_output.side_effect = OSError('Unknown OSError')
244 self.assert_run_simple_daemon_success()
245
Zhuoyao Zhang205a2fc2024-09-20 18:19:59 +0000246 @mock.patch('os.execv')
247 def test_monitor_daemon_reboot_triggered(self, mock_execv):
248 binary_file = tempfile.NamedTemporaryFile(
249 dir=self.working_dir.name, delete=False
250 )
251
252 dm = daemon_manager.DaemonManager(
Zhuoyao Zhangba64f312024-10-14 20:32:53 +0000253 binary_file.name,
254 daemon_target=long_running_daemon,
Zhuoyao Zhang205a2fc2024-09-20 18:19:59 +0000255 )
256 dm.start()
257 dm.monitor_daemon(reboot_timeout=0.5)
258 mock_execv.assert_called_once()
259
Zhuoyao Zhang53359552024-09-16 23:58:11 +0000260 def test_stop_success(self):
261 dm = daemon_manager.DaemonManager(
262 TEST_BINARY_FILE, daemon_target=long_running_daemon
263 )
264 dm.start()
265 dm.stop()
266
267 self.assert_no_subprocess_running()
268 self.assertFalse(dm.pid_file_path.exists())
269
270 @mock.patch('os.kill')
271 def test_stop_failed_to_kill_daemon_process(self, mock_kill):
272 mock_kill.side_effect = OSError('Unknown OSError')
Zhuoyao Zhangba64f312024-10-14 20:32:53 +0000273 fake_cclient = FakeClearcutClient()
Zhuoyao Zhang53359552024-09-16 23:58:11 +0000274 dm = daemon_manager.DaemonManager(
Zhuoyao Zhangba64f312024-10-14 20:32:53 +0000275 TEST_BINARY_FILE,
276 daemon_target=long_running_daemon,
277 cclient=fake_cclient,
Zhuoyao Zhang53359552024-09-16 23:58:11 +0000278 )
Zhuoyao Zhang53359552024-09-16 23:58:11 +0000279
Zhuoyao Zhangba64f312024-10-14 20:32:53 +0000280 with self.assertRaises(SystemExit):
281 dm.start()
282 dm.stop()
283 self.assertTrue(dm.daemon_process.is_alive())
284 self.assertTrue(dm.pid_file_path.exists())
285 self._assert_error_event_logged(
286 fake_cclient, edit_event_pb2.EditEvent.FAILED_TO_STOP_EDIT_MONITOR
287 )
Zhuoyao Zhang53359552024-09-16 23:58:11 +0000288
289 @mock.patch('os.remove')
290 def test_stop_failed_to_remove_pidfile(self, mock_remove):
291 mock_remove.side_effect = OSError('Unknown OSError')
292
Zhuoyao Zhangba64f312024-10-14 20:32:53 +0000293 fake_cclient = FakeClearcutClient()
Zhuoyao Zhang53359552024-09-16 23:58:11 +0000294 dm = daemon_manager.DaemonManager(
Zhuoyao Zhangba64f312024-10-14 20:32:53 +0000295 TEST_BINARY_FILE,
296 daemon_target=long_running_daemon,
297 cclient=fake_cclient,
Zhuoyao Zhang53359552024-09-16 23:58:11 +0000298 )
Zhuoyao Zhang53359552024-09-16 23:58:11 +0000299
Zhuoyao Zhangba64f312024-10-14 20:32:53 +0000300 with self.assertRaises(SystemExit):
301 dm.start()
302 dm.stop()
303 self.assert_no_subprocess_running()
304 self.assertTrue(dm.pid_file_path.exists())
305
306 self._assert_error_event_logged(
307 fake_cclient, edit_event_pb2.EditEvent.FAILED_TO_STOP_EDIT_MONITOR
308 )
Zhuoyao Zhang53359552024-09-16 23:58:11 +0000309
Zhuoyao Zhang205a2fc2024-09-20 18:19:59 +0000310 @mock.patch('os.execv')
311 def test_reboot_success(self, mock_execv):
312 binary_file = tempfile.NamedTemporaryFile(
313 dir=self.working_dir.name, delete=False
314 )
315
316 dm = daemon_manager.DaemonManager(
317 binary_file.name, daemon_target=long_running_daemon
318 )
319 dm.start()
320 dm.reboot()
321
322 # Verifies the old process is stopped
323 self.assert_no_subprocess_running()
324 self.assertFalse(dm.pid_file_path.exists())
325
326 mock_execv.assert_called_once()
327
328 @mock.patch('os.execv')
329 def test_reboot_binary_no_longer_exists(self, mock_execv):
330 dm = daemon_manager.DaemonManager(
331 TEST_BINARY_FILE, daemon_target=long_running_daemon
332 )
333 dm.start()
334
Zhuoyao Zhangba64f312024-10-14 20:32:53 +0000335 with self.assertRaises(SystemExit):
Zhuoyao Zhang205a2fc2024-09-20 18:19:59 +0000336 dm.reboot()
337 mock_execv.assert_not_called()
338 self.assertEqual(cm.exception.code, 0)
339
340 @mock.patch('os.execv')
341 def test_reboot_failed(self, mock_execv):
342 mock_execv.side_effect = OSError('Unknown OSError')
Zhuoyao Zhangba64f312024-10-14 20:32:53 +0000343 fake_cclient = FakeClearcutClient()
Zhuoyao Zhang205a2fc2024-09-20 18:19:59 +0000344 binary_file = tempfile.NamedTemporaryFile(
345 dir=self.working_dir.name, delete=False
346 )
347
348 dm = daemon_manager.DaemonManager(
Zhuoyao Zhangba64f312024-10-14 20:32:53 +0000349 binary_file.name,
350 daemon_target=long_running_daemon,
351 cclient=fake_cclient,
Zhuoyao Zhang205a2fc2024-09-20 18:19:59 +0000352 )
353 dm.start()
354
Zhuoyao Zhangba64f312024-10-14 20:32:53 +0000355 with self.assertRaises(SystemExit):
Zhuoyao Zhang205a2fc2024-09-20 18:19:59 +0000356 dm.reboot()
357 self.assertEqual(cm.exception.code, 1)
Zhuoyao Zhangba64f312024-10-14 20:32:53 +0000358 self._assert_error_event_logged(
359 fake_cclient, edit_event_pb2.EditEvent.FAILED_TO_REBOOT_EDIT_MONITOR
360 )
Zhuoyao Zhang205a2fc2024-09-20 18:19:59 +0000361
Zhuoyao Zhang4d485592024-09-17 21:14:38 +0000362 def assert_run_simple_daemon_success(self):
363 damone_output_file = tempfile.NamedTemporaryFile(
364 dir=self.working_dir.name, delete=False
365 )
366 dm = daemon_manager.DaemonManager(
367 TEST_BINARY_FILE,
368 daemon_target=simple_daemon,
369 daemon_args=(damone_output_file.name,),
370 )
371 dm.start()
Zhuoyao Zhangdc2840d2024-09-19 23:29:27 +0000372 dm.monitor_daemon(interval=1)
Zhuoyao Zhang4d485592024-09-17 21:14:38 +0000373
374 # Verifies the expected pid file is created.
375 expected_pid_file_path = pathlib.Path(self.working_dir.name).joinpath(
376 'edit_monitor', TEST_PID_FILE_PATH
377 )
378 self.assertTrue(expected_pid_file_path.exists())
379
380 # Verify the daemon process is executed successfully.
381 with open(damone_output_file.name, 'r') as f:
382 contents = f.read()
383 self.assertEqual(contents, 'running daemon target')
384
Zhuoyao Zhang53359552024-09-16 23:58:11 +0000385 def assert_no_subprocess_running(self):
386 child_pids = self._get_child_processes(os.getpid())
387 for child_pid in child_pids:
388 self.assertFalse(
389 self._is_process_alive(child_pid), f'process {child_pid} still alive'
390 )
391
Zhuoyao Zhangd28da5c2024-09-24 19:46:12 +0000392 def _get_child_processes(self, parent_pid: int) -> list[int]:
Zhuoyao Zhang53359552024-09-16 23:58:11 +0000393 try:
394 output = subprocess.check_output(
395 ['ps', '-o', 'pid,ppid', '--no-headers'], text=True
396 )
397
398 child_processes = []
399 for line in output.splitlines():
400 pid, ppid = line.split()
401 if int(ppid) == parent_pid:
402 child_processes.append(int(pid))
403 return child_processes
404 except subprocess.CalledProcessError as e:
405 self.fail(f'failed to get child process, error: {e}')
406
Zhuoyao Zhangd28da5c2024-09-24 19:46:12 +0000407 def _is_process_alive(self, pid: int) -> bool:
Zhuoyao Zhang53359552024-09-16 23:58:11 +0000408 try:
409 output = subprocess.check_output(
410 ['ps', '-p', str(pid), '-o', 'state='], text=True
411 ).strip()
412 state = output.split()[0]
413 return state != 'Z' # Check if the state is not 'Z' (zombie)
414 except subprocess.CalledProcessError:
415 return False
416
417 def _cleanup_child_processes(self):
418 child_pids = self._get_child_processes(os.getpid())
419 for child_pid in child_pids:
420 try:
421 os.kill(child_pid, signal.SIGKILL)
422 except ProcessLookupError:
423 # process already terminated
424 pass
425
Zhuoyao Zhangd28da5c2024-09-24 19:46:12 +0000426 def _create_fake_deamon_process(
427 self, name: str = ''
428 ) -> multiprocessing.Process:
429 # Create a long running subprocess
430 p = multiprocessing.Process(target=long_running_daemon)
431 p.start()
432
433 # Create the pidfile with the subprocess pid
434 pid_file_path_dir = pathlib.Path(self.working_dir.name).joinpath(
435 'edit_monitor'
436 )
437 pid_file_path_dir.mkdir(parents=True, exist_ok=True)
438 with open(pid_file_path_dir.joinpath(name + 'pid.lock'), 'w') as f:
439 f.write(str(p.pid))
440 return p
441
Zhuoyao Zhangba64f312024-10-14 20:32:53 +0000442 def _assert_error_event_logged(self, fake_cclient, error_type):
443 error_events = fake_cclient.get_sent_events()
444 self.assertEquals(len(error_events), 1)
445 self.assertEquals(
446 edit_event_pb2.EditEvent.FromString(
447 error_events[0].source_extension
448 ).edit_monitor_error_event.error_type,
449 error_type,
450 )
451
452
453class FakeClearcutClient:
454
455 def __init__(self):
456 self.pending_log_events = []
457 self.sent_log_event = []
458
459 def log(self, log_event):
460 self.pending_log_events.append(log_event)
461
462 def flush_events(self):
463 self.sent_log_event.extend(self.pending_log_events)
464 self.pending_log_events.clear()
465
466 def get_sent_events(self):
467 return self.sent_log_event + self.pending_log_events
468
Zhuoyao Zhang53359552024-09-16 23:58:11 +0000469
470if __name__ == '__main__':
471 unittest.main()