| #!/usr/bin/env python3 |
| # |
| # SPDX-License-Identifier: GPL-2.0-or-later |
| # |
| # Functional test for dynamic QMP monitor hotplug |
| # |
| # Copyright (c) 2026 Christian Brauner |
| |
| import asyncio |
| import os |
| import random |
| import threading |
| import time |
| |
| from qemu_test import QemuSystemTest |
| |
| from qemu.qmp.legacy import QEMUMonitorProtocol |
| from qemu.qmp import QMPClient |
| |
| |
| class MonitorHotplug(QemuSystemTest): |
| |
| def setUp(self): |
| super().setUp() |
| sock_dir = self.socket_dir() |
| self._sock_path = os.path.join(sock_dir.name, 'hotplug.sock') |
| |
| def _add_monitor(self, autodelete=False): |
| """Create a chardev + monitor and return the socket path.""" |
| sock = self._sock_path |
| self.vm.cmd('chardev-add', id='hotplug-chr', backend={ |
| 'type': 'socket', |
| 'data': { |
| 'addr': { |
| 'type': 'unix', |
| 'data': {'path': sock} |
| }, |
| 'server': True, |
| 'wait': False, |
| } |
| }) |
| if autodelete: |
| self.vm.cmd('object-add', id='hotplug-mon', |
| qom_type='monitor-qmp', |
| chardev='hotplug-chr', |
| close_action='delete') |
| else: |
| self.vm.cmd('object-add', id='hotplug-mon', |
| qom_type='monitor-qmp', |
| chardev='hotplug-chr') |
| return sock |
| |
| def _remove_monitor(self): |
| """Remove the monitor + chardev.""" |
| self.vm.cmd('object-del', id='hotplug-mon') |
| self.vm.cmd('chardev-remove', id='hotplug-chr') |
| |
| def _connect_and_handshake(self, sock_path): |
| """ |
| Connect to the dynamic monitor socket, perform the QMP |
| greeting and capability negotiation, send a command, then |
| disconnect. |
| """ |
| qmp = QEMUMonitorProtocol(sock_path) |
| |
| # connect(negotiate=True) receives the greeting, validates it, |
| # and sends qmp_capabilities automatically. |
| greeting = qmp.connect(negotiate=True) |
| self.assertIn('QMP', greeting) |
| self.assertIn('version', greeting['QMP']) |
| self.assertIn('capabilities', greeting['QMP']) |
| |
| # Send a real command to prove the session is fully functional |
| resp = qmp.cmd_obj({'execute': 'query-version'}) |
| self.assertIn('return', resp) |
| self.assertIn('qemu', resp['return']) |
| |
| qmp.close() |
| |
| def test_hotplug_cycle(self): |
| """ |
| Hotplug a monitor, do the full QMP handshake, unplug it, |
| then repeat the whole cycle a second time. |
| """ |
| self.set_machine('none') |
| self.vm.add_args('-nodefaults') |
| self.vm.launch() |
| |
| # First cycle |
| sock = self._add_monitor() |
| self._connect_and_handshake(sock) |
| self._remove_monitor() |
| |
| # Second cycle -- same ids, same path, must work |
| sock = self._add_monitor() |
| self._connect_and_handshake(sock) |
| self._remove_monitor() |
| |
| def test_self_removal(self): |
| """ |
| A dynamically-added monitor sends object-del targeting |
| itself. Verify the request is rejected, but the monitor |
| can still be deleted from outside its own context. |
| """ |
| self.set_machine('none') |
| self.vm.add_args('-nodefaults') |
| self.vm.launch() |
| |
| sock = self._add_monitor() |
| |
| qmp = QEMUMonitorProtocol(sock) |
| greeting = qmp.connect(negotiate=True) |
| self.assertIn('QMP', greeting) |
| |
| # Self-removal: the dynamic monitor raises error |
| resp = qmp.cmd_obj({'execute': 'object-del', |
| 'arguments': {'id': 'hotplug-mon'}}) |
| self.assertIn('error', resp) |
| |
| qmp.close() |
| |
| resp = self.vm.cmd('object-del', id='hotplug-mon') |
| |
| # Clean up the chardev |
| self.vm.cmd('chardev-remove', id='hotplug-chr') |
| |
| def test_auto_delete(self): |
| """ |
| A dynamically-added monitor configured with 'close-action=delete' |
| should see itself deleted when the client is closed. |
| """ |
| self.set_machine('none') |
| self.vm.add_args('-nodefaults') |
| self.vm.launch() |
| |
| sock = self._add_monitor(autodelete=True) |
| |
| cdevs = [c["label"] for c in self.vm.cmd('query-chardev')] |
| objs = [o["name"] for o in self.vm.cmd('qom-list', path='/objects')] |
| assert ('hotplug-chr' in cdevs) |
| assert ('hotplug-mon' in objs) |
| |
| qmp = QEMUMonitorProtocol(sock) |
| greeting = qmp.connect(negotiate=True) |
| self.assertIn('QMP', greeting) |
| |
| cdevs = [c["label"] for c in self.vm.cmd('query-chardev')] |
| objs = [o["name"] for o in self.vm.cmd('qom-list', path='/objects')] |
| assert ('hotplug-chr' in cdevs) |
| assert ('hotplug-mon' in objs) |
| |
| qmp.close() |
| |
| # Wait upto 10 seconds max for chardev to auto-delete, which |
| # is hopefully enough for reliability under high load |
| for i in range(int(10 / 0.2)): |
| cdevs = [c["label"] for c in self.vm.cmd('query-chardev')] |
| if 'hotplug-chr' not in cdevs: |
| break |
| # Wait a little more then try again |
| time.sleep(0.2) |
| |
| cdevs = [c["label"] for c in self.vm.cmd('query-chardev')] |
| objs = [o["name"] for o in self.vm.cmd('qom-list', path='/objects')] |
| assert ('hotplug-chr' not in cdevs) |
| assert ('hotplug-mon' not in objs) |
| |
| def test_reconnect(self): |
| """ |
| A dynamically-added monitor configured without 'close-action' |
| should allow reconnects after the client is closed. |
| """ |
| self.set_machine('none') |
| self.vm.add_args('-nodefaults') |
| self.vm.launch() |
| |
| sock = self._add_monitor() |
| |
| qmp = QEMUMonitorProtocol(sock) |
| qmp.connect(negotiate=True) |
| |
| resp = qmp.cmd_obj({'execute': 'query-chardev'}) |
| self.assertIn('return', resp) |
| |
| qmp.close() |
| |
| qmp = QEMUMonitorProtocol(sock) |
| qmp.connect(negotiate=True) |
| |
| resp = qmp.cmd_obj({'execute': 'query-chardev'}) |
| self.assertIn('return', resp) |
| |
| qmp.close() |
| self._remove_monitor() |
| |
| def test_large_response(self): |
| """ |
| Send a command with a large response (query-qmp-schema) on a |
| dynamically-added monitor to exercise the output buffer flush |
| path. |
| """ |
| self.set_machine('none') |
| self.vm.add_args('-nodefaults') |
| self.vm.launch() |
| |
| sock = self._add_monitor() |
| |
| qmp = QEMUMonitorProtocol(sock) |
| qmp.connect(negotiate=True) |
| |
| resp = qmp.cmd_obj({'execute': 'query-qmp-schema'}) |
| self.assertIn('return', resp) |
| self.assertIsInstance(resp['return'], list) |
| self.assertGreater(len(resp['return']), 0) |
| |
| qmp.close() |
| self._remove_monitor() |
| |
| def test_events_after_negotiation(self): |
| """ |
| Verify that QMP events are delivered on a dynamically-added |
| monitor after capability negotiation completes. |
| """ |
| self.set_machine('none') |
| self.vm.add_args('-nodefaults') |
| self.vm.launch() |
| |
| sock = self._add_monitor() |
| |
| qmp = QEMUMonitorProtocol(sock) |
| qmp.connect(negotiate=True) |
| |
| # Trigger a STOP event via the main monitor, then read it |
| # from the dynamic monitor. |
| self.vm.cmd('stop') |
| resp = qmp.pull_event(wait=True) |
| self.assertEqual(resp['event'], 'STOP') |
| |
| self.vm.cmd('cont') |
| resp = qmp.pull_event(wait=True) |
| self.assertEqual(resp['event'], 'RESUME') |
| |
| qmp.close() |
| self._remove_monitor() |
| |
| def stress_mon(self, sock): |
| async def main(): |
| qmp = QMPClient('testvm') |
| await qmp.connect(sock) |
| # Run query-version in a tight loop so that the |
| # monitor thread/dispatcher is very busy at the |
| # time we try to delete the monitor |
| while True: |
| try: |
| # A command which returns a lot of data to make |
| # it more likely we're in the I/O reply path |
| # when deleting the monitor |
| res = await qmp.execute('query-qmp-schema') |
| # Some commands which generate async events |
| # as those can trigger different code paths |
| res = await qmp.execute('stop') |
| res = await qmp.execute('cont') |
| except: |
| # we'll get here if the monitor is terminated |
| # by QEMU in which case we must disconnect |
| # out side, but.... |
| try: |
| await qmp.disconnect() |
| except (ConnectionResetError, EOFError, BrokenPipeError): |
| # ... disconnect() will probably see |
| # errors too, but we must try to call it |
| # regardless to cleanup asyncio state |
| # and prevent python warnings at GC time |
| pass |
| return |
| asyncio.run(main()) |
| |
| def test_hotplug_stress(self): |
| """ |
| Repeatedly hotplug and unplug a monitor, while another thread |
| concurrently issues commands on that monitor. This stresses |
| the synchronization with the monitor thread during cleanup |
| """ |
| self.set_machine('none') |
| self.vm.add_args('-nodefaults') |
| self.vm.launch() |
| |
| # Each loop sleeps at most 0.5 seconds, so this should |
| # give an upper bound of approx 5 seconds execution |
| # time which is reasonable to run by default |
| repeat = 10 |
| for i in range(repeat): |
| # First cycle |
| sock = self._add_monitor() |
| print ("# stress cycle %02d/%02d" % (i, repeat)) |
| stress = threading.Thread(target=self.stress_mon, args=[sock]) |
| stress.start() |
| # Sleep upto 1/2 second to vary the races |
| time.sleep(random.random() / 2) |
| self._remove_monitor() |
| stress.join() |
| |
| |
| if __name__ == '__main__': |
| QemuSystemTest.main() |