blob: 214b0388dc7cabe74fbfe245421e899947191196 [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
46class DaemonManagerTest(unittest.TestCase):
47
48 @classmethod
49 def setUpClass(cls):
50 super().setUpClass()
51 # Configure to print logging to stdout.
52 logging.basicConfig(filename=None, level=logging.DEBUG)
53 console = logging.StreamHandler(sys.stdout)
54 logging.getLogger('').addHandler(console)
55
56 def setUp(self):
57 super().setUp()
58 self.original_tempdir = tempfile.tempdir
59 self.working_dir = tempfile.TemporaryDirectory()
60 # Sets the tempdir under the working dir so any temp files created during
61 # tests will be cleaned.
62 tempfile.tempdir = self.working_dir.name
63
64 def tearDown(self):
65 # Cleans up any child processes left by the tests.
66 self._cleanup_child_processes()
67 self.working_dir.cleanup()
68 # Restores tempdir.
69 tempfile.tempdir = self.original_tempdir
70 super().tearDown()
71
Zhuoyao Zhang4d485592024-09-17 21:14:38 +000072 def test_start_success_with_no_existing_instance(self):
73 self.assert_run_simple_daemon_success()
74
75 def test_start_success_with_existing_instance_running(self):
76 # Create a long running subprocess
77 p = multiprocessing.Process(target=long_running_daemon)
78 p.start()
79
80 # Create a pidfile with the subprocess pid
81 pid_file_path_dir = pathlib.Path(self.working_dir.name).joinpath(
82 'edit_monitor'
Zhuoyao Zhang53359552024-09-16 23:58:11 +000083 )
Zhuoyao Zhang4d485592024-09-17 21:14:38 +000084 pid_file_path_dir.mkdir(parents=True, exist_ok=True)
85 with open(pid_file_path_dir.joinpath(TEST_PID_FILE_PATH), 'w') as f:
86 f.write(str(p.pid))
87
88 self.assert_run_simple_daemon_success()
89 p.terminate()
90
91 def test_start_success_with_existing_instance_already_dead(self):
92 # Create a pidfile with pid that does not exist.
93 pid_file_path_dir = pathlib.Path(self.working_dir.name).joinpath(
94 'edit_monitor'
Zhuoyao Zhang53359552024-09-16 23:58:11 +000095 )
Zhuoyao Zhang4d485592024-09-17 21:14:38 +000096 pid_file_path_dir.mkdir(parents=True, exist_ok=True)
97 with open(pid_file_path_dir.joinpath(TEST_PID_FILE_PATH), 'w') as f:
98 f.write('123456')
99
100 self.assert_run_simple_daemon_success()
101
102 def test_start_success_with_existing_instance_from_different_binary(self):
103 # First start an instance based on "some_binary_path"
104 existing_dm = daemon_manager.DaemonManager(
105 "some_binary_path",
106 daemon_target=long_running_daemon,
107 )
108 existing_dm.start()
109
110 self.assert_run_simple_daemon_success()
111 existing_dm.stop()
112
113 @mock.patch('os.kill')
114 def test_start_failed_to_kill_existing_instance(self, mock_kill):
115 mock_kill.side_effect = OSError('Unknown OSError')
116 pid_file_path_dir = pathlib.Path(self.working_dir.name).joinpath(
117 'edit_monitor'
118 )
119 pid_file_path_dir.mkdir(parents=True, exist_ok=True)
120 with open(pid_file_path_dir.joinpath(TEST_PID_FILE_PATH), 'w') as f:
121 f.write('123456')
122
123 dm = daemon_manager.DaemonManager(TEST_BINARY_FILE)
Zhuoyao Zhang53359552024-09-16 23:58:11 +0000124 dm.start()
Zhuoyao Zhang53359552024-09-16 23:58:11 +0000125
Zhuoyao Zhang4d485592024-09-17 21:14:38 +0000126 # Verify no daemon process is started.
127 self.assertIsNone(dm.daemon_process)
Zhuoyao Zhang53359552024-09-16 23:58:11 +0000128
129 def test_start_failed_to_write_pidfile(self):
130 pid_file_path_dir = pathlib.Path(self.working_dir.name).joinpath(
131 'edit_monitor'
132 )
133 pid_file_path_dir.mkdir(parents=True, exist_ok=True)
134 # Makes the directory read-only so write pidfile will fail.
135 os.chmod(pid_file_path_dir, 0o555)
136
137 dm = daemon_manager.DaemonManager(TEST_BINARY_FILE)
138 dm.start()
139
140 # Verifies no daemon process is started.
141 self.assertIsNone(dm.daemon_process)
142
143 def test_start_failed_to_start_daemon_process(self):
144 dm = daemon_manager.DaemonManager(
145 TEST_BINARY_FILE, daemon_target='wrong_target', daemon_args=(1)
146 )
147 dm.start()
148
149 # Verifies no daemon process is started.
150 self.assertIsNone(dm.daemon_process)
151
152 def test_stop_success(self):
153 dm = daemon_manager.DaemonManager(
154 TEST_BINARY_FILE, daemon_target=long_running_daemon
155 )
156 dm.start()
157 dm.stop()
158
159 self.assert_no_subprocess_running()
160 self.assertFalse(dm.pid_file_path.exists())
161
162 @mock.patch('os.kill')
163 def test_stop_failed_to_kill_daemon_process(self, mock_kill):
164 mock_kill.side_effect = OSError('Unknown OSError')
165 dm = daemon_manager.DaemonManager(
166 TEST_BINARY_FILE, daemon_target=long_running_daemon
167 )
168 dm.start()
169 dm.stop()
170
171 self.assertTrue(dm.daemon_process.is_alive())
172 self.assertTrue(dm.pid_file_path.exists())
173
174 @mock.patch('os.remove')
175 def test_stop_failed_to_remove_pidfile(self, mock_remove):
176 mock_remove.side_effect = OSError('Unknown OSError')
177
178 dm = daemon_manager.DaemonManager(
179 TEST_BINARY_FILE, daemon_target=long_running_daemon
180 )
181 dm.start()
182 dm.stop()
183
184 self.assert_no_subprocess_running()
185 self.assertTrue(dm.pid_file_path.exists())
186
Zhuoyao Zhang4d485592024-09-17 21:14:38 +0000187 def assert_run_simple_daemon_success(self):
188 damone_output_file = tempfile.NamedTemporaryFile(
189 dir=self.working_dir.name, delete=False
190 )
191 dm = daemon_manager.DaemonManager(
192 TEST_BINARY_FILE,
193 daemon_target=simple_daemon,
194 daemon_args=(damone_output_file.name,),
195 )
196 dm.start()
197 dm.daemon_process.join()
198
199 # Verifies the expected pid file is created.
200 expected_pid_file_path = pathlib.Path(self.working_dir.name).joinpath(
201 'edit_monitor', TEST_PID_FILE_PATH
202 )
203 self.assertTrue(expected_pid_file_path.exists())
204
205 # Verify the daemon process is executed successfully.
206 with open(damone_output_file.name, 'r') as f:
207 contents = f.read()
208 self.assertEqual(contents, 'running daemon target')
209
Zhuoyao Zhang53359552024-09-16 23:58:11 +0000210 def assert_no_subprocess_running(self):
211 child_pids = self._get_child_processes(os.getpid())
212 for child_pid in child_pids:
213 self.assertFalse(
214 self._is_process_alive(child_pid), f'process {child_pid} still alive'
215 )
216
217 def _get_child_processes(self, parent_pid):
218 try:
219 output = subprocess.check_output(
220 ['ps', '-o', 'pid,ppid', '--no-headers'], text=True
221 )
222
223 child_processes = []
224 for line in output.splitlines():
225 pid, ppid = line.split()
226 if int(ppid) == parent_pid:
227 child_processes.append(int(pid))
228 return child_processes
229 except subprocess.CalledProcessError as e:
230 self.fail(f'failed to get child process, error: {e}')
231
232 def _is_process_alive(self, pid):
233 try:
234 output = subprocess.check_output(
235 ['ps', '-p', str(pid), '-o', 'state='], text=True
236 ).strip()
237 state = output.split()[0]
238 return state != 'Z' # Check if the state is not 'Z' (zombie)
239 except subprocess.CalledProcessError:
240 return False
241
242 def _cleanup_child_processes(self):
243 child_pids = self._get_child_processes(os.getpid())
244 for child_pid in child_pids:
245 try:
246 os.kill(child_pid, signal.SIGKILL)
247 except ProcessLookupError:
248 # process already terminated
249 pass
250
251
252if __name__ == '__main__':
253 unittest.main()