master
py 305 lines 9.94 KB
Raw
1 #!/usr/bin/env python3
2 #
3 # SPDX-License-Identifier: GPL-2.0-or-later
4 #
5 # Functional test for dynamic QMP monitor hotplug
6 #
7 # Copyright (c) 2026 Christian Brauner
8
9 import asyncio
10 import os
11 import random
12 import threading
13 import time
14
15 from qemu_test import QemuSystemTest
16
17 from qemu.qmp.legacy import QEMUMonitorProtocol
18 from qemu.qmp import QMPClient
19
20
21 class MonitorHotplug(QemuSystemTest):
22
23 def setUp(self):
24 super().setUp()
25 sock_dir = self.socket_dir()
26 self._sock_path = os.path.join(sock_dir.name, 'hotplug.sock')
27
28 def _add_monitor(self, autodelete=False):
29 """Create a chardev + monitor and return the socket path."""
30 sock = self._sock_path
31 self.vm.cmd('chardev-add', id='hotplug-chr', backend={
32 'type': 'socket',
33 'data': {
34 'addr': {
35 'type': 'unix',
36 'data': {'path': sock}
37 },
38 'server': True,
39 'wait': False,
40 }
41 })
42 if autodelete:
43 self.vm.cmd('object-add', id='hotplug-mon',
44 qom_type='monitor-qmp',
45 chardev='hotplug-chr',
46 close_action='delete')
47 else:
48 self.vm.cmd('object-add', id='hotplug-mon',
49 qom_type='monitor-qmp',
50 chardev='hotplug-chr')
51 return sock
52
53 def _remove_monitor(self):
54 """Remove the monitor + chardev."""
55 self.vm.cmd('object-del', id='hotplug-mon')
56 self.vm.cmd('chardev-remove', id='hotplug-chr')
57
58 def _connect_and_handshake(self, sock_path):
59 """
60 Connect to the dynamic monitor socket, perform the QMP
61 greeting and capability negotiation, send a command, then
62 disconnect.
63 """
64 qmp = QEMUMonitorProtocol(sock_path)
65
66 # connect(negotiate=True) receives the greeting, validates it,
67 # and sends qmp_capabilities automatically.
68 greeting = qmp.connect(negotiate=True)
69 self.assertIn('QMP', greeting)
70 self.assertIn('version', greeting['QMP'])
71 self.assertIn('capabilities', greeting['QMP'])
72
73 # Send a real command to prove the session is fully functional
74 resp = qmp.cmd_obj({'execute': 'query-version'})
75 self.assertIn('return', resp)
76 self.assertIn('qemu', resp['return'])
77
78 qmp.close()
79
80 def test_hotplug_cycle(self):
81 """
82 Hotplug a monitor, do the full QMP handshake, unplug it,
83 then repeat the whole cycle a second time.
84 """
85 self.set_machine('none')
86 self.vm.add_args('-nodefaults')
87 self.vm.launch()
88
89 # First cycle
90 sock = self._add_monitor()
91 self._connect_and_handshake(sock)
92 self._remove_monitor()
93
94 # Second cycle -- same ids, same path, must work
95 sock = self._add_monitor()
96 self._connect_and_handshake(sock)
97 self._remove_monitor()
98
99 def test_self_removal(self):
100 """
101 A dynamically-added monitor sends object-del targeting
102 itself. Verify the request is rejected, but the monitor
103 can still be deleted from outside its own context.
104 """
105 self.set_machine('none')
106 self.vm.add_args('-nodefaults')
107 self.vm.launch()
108
109 sock = self._add_monitor()
110
111 qmp = QEMUMonitorProtocol(sock)
112 greeting = qmp.connect(negotiate=True)
113 self.assertIn('QMP', greeting)
114
115 # Self-removal: the dynamic monitor raises error
116 resp = qmp.cmd_obj({'execute': 'object-del',
117 'arguments': {'id': 'hotplug-mon'}})
118 self.assertIn('error', resp)
119
120 qmp.close()
121
122 resp = self.vm.cmd('object-del', id='hotplug-mon')
123
124 # Clean up the chardev
125 self.vm.cmd('chardev-remove', id='hotplug-chr')
126
127 def test_auto_delete(self):
128 """
129 A dynamically-added monitor configured with 'close-action=delete'
130 should see itself deleted when the client is closed.
131 """
132 self.set_machine('none')
133 self.vm.add_args('-nodefaults')
134 self.vm.launch()
135
136 sock = self._add_monitor(autodelete=True)
137
138 cdevs = [c["label"] for c in self.vm.cmd('query-chardev')]
139 objs = [o["name"] for o in self.vm.cmd('qom-list', path='/objects')]
140 assert ('hotplug-chr' in cdevs)
141 assert ('hotplug-mon' in objs)
142
143 qmp = QEMUMonitorProtocol(sock)
144 greeting = qmp.connect(negotiate=True)
145 self.assertIn('QMP', greeting)
146
147 cdevs = [c["label"] for c in self.vm.cmd('query-chardev')]
148 objs = [o["name"] for o in self.vm.cmd('qom-list', path='/objects')]
149 assert ('hotplug-chr' in cdevs)
150 assert ('hotplug-mon' in objs)
151
152 qmp.close()
153
154 # Wait upto 10 seconds max for chardev to auto-delete, which
155 # is hopefully enough for reliability under high load
156 for i in range(int(10 / 0.2)):
157 cdevs = [c["label"] for c in self.vm.cmd('query-chardev')]
158 if 'hotplug-chr' not in cdevs:
159 break
160 # Wait a little more then try again
161 time.sleep(0.2)
162
163 cdevs = [c["label"] for c in self.vm.cmd('query-chardev')]
164 objs = [o["name"] for o in self.vm.cmd('qom-list', path='/objects')]
165 assert ('hotplug-chr' not in cdevs)
166 assert ('hotplug-mon' not in objs)
167
168 def test_reconnect(self):
169 """
170 A dynamically-added monitor configured without 'close-action'
171 should allow reconnects after the client is closed.
172 """
173 self.set_machine('none')
174 self.vm.add_args('-nodefaults')
175 self.vm.launch()
176
177 sock = self._add_monitor()
178
179 qmp = QEMUMonitorProtocol(sock)
180 qmp.connect(negotiate=True)
181
182 resp = qmp.cmd_obj({'execute': 'query-chardev'})
183 self.assertIn('return', resp)
184
185 qmp.close()
186
187 qmp = QEMUMonitorProtocol(sock)
188 qmp.connect(negotiate=True)
189
190 resp = qmp.cmd_obj({'execute': 'query-chardev'})
191 self.assertIn('return', resp)
192
193 qmp.close()
194 self._remove_monitor()
195
196 def test_large_response(self):
197 """
198 Send a command with a large response (query-qmp-schema) on a
199 dynamically-added monitor to exercise the output buffer flush
200 path.
201 """
202 self.set_machine('none')
203 self.vm.add_args('-nodefaults')
204 self.vm.launch()
205
206 sock = self._add_monitor()
207
208 qmp = QEMUMonitorProtocol(sock)
209 qmp.connect(negotiate=True)
210
211 resp = qmp.cmd_obj({'execute': 'query-qmp-schema'})
212 self.assertIn('return', resp)
213 self.assertIsInstance(resp['return'], list)
214 self.assertGreater(len(resp['return']), 0)
215
216 qmp.close()
217 self._remove_monitor()
218
219 def test_events_after_negotiation(self):
220 """
221 Verify that QMP events are delivered on a dynamically-added
222 monitor after capability negotiation completes.
223 """
224 self.set_machine('none')
225 self.vm.add_args('-nodefaults')
226 self.vm.launch()
227
228 sock = self._add_monitor()
229
230 qmp = QEMUMonitorProtocol(sock)
231 qmp.connect(negotiate=True)
232
233 # Trigger a STOP event via the main monitor, then read it
234 # from the dynamic monitor.
235 self.vm.cmd('stop')
236 resp = qmp.pull_event(wait=True)
237 self.assertEqual(resp['event'], 'STOP')
238
239 self.vm.cmd('cont')
240 resp = qmp.pull_event(wait=True)
241 self.assertEqual(resp['event'], 'RESUME')
242
243 qmp.close()
244 self._remove_monitor()
245
246 def stress_mon(self, sock):
247 async def main():
248 qmp = QMPClient('testvm')
249 await qmp.connect(sock)
250 # Run query-version in a tight loop so that the
251 # monitor thread/dispatcher is very busy at the
252 # time we try to delete the monitor
253 while True:
254 try:
255 # A command which returns a lot of data to make
256 # it more likely we're in the I/O reply path
257 # when deleting the monitor
258 res = await qmp.execute('query-qmp-schema')
259 # Some commands which generate async events
260 # as those can trigger different code paths
261 res = await qmp.execute('stop')
262 res = await qmp.execute('cont')
263 except:
264 # we'll get here if the monitor is terminated
265 # by QEMU in which case we must disconnect
266 # out side, but....
267 try:
268 await qmp.disconnect()
269 except (ConnectionResetError, EOFError, BrokenPipeError):
270 # ... disconnect() will probably see
271 # errors too, but we must try to call it
272 # regardless to cleanup asyncio state
273 # and prevent python warnings at GC time
274 pass
275 return
276 asyncio.run(main())
277
278 def test_hotplug_stress(self):
279 """
280 Repeatedly hotplug and unplug a monitor, while another thread
281 concurrently issues commands on that monitor. This stresses
282 the synchronization with the monitor thread during cleanup
283 """
284 self.set_machine('none')
285 self.vm.add_args('-nodefaults')
286 self.vm.launch()
287
288 # Each loop sleeps at most 0.5 seconds, so this should
289 # give an upper bound of approx 5 seconds execution
290 # time which is reasonable to run by default
291 repeat = 10
292 for i in range(repeat):
293 # First cycle
294 sock = self._add_monitor()
295 print ("# stress cycle %02d/%02d" % (i, repeat))
296 stress = threading.Thread(target=self.stress_mon, args=[sock])
297 stress.start()
298 # Sleep upto 1/2 second to vary the races
299 time.sleep(random.random() / 2)
300 self._remove_monitor()
301 stress.join()
302
303
304 if __name__ == '__main__':
305 QemuSystemTest.main()