Files
qemu-qemu-1/tests/functional/generic/test_monitor_hotplug.py
Daniel P. Berrangé 6c479e70e2 monitor: add support for auto-deleting monitors upon close
The default monitor is usually a long lived object that will exist for
the entire lifetime of the VM. A monitor can only service a single
client at a time though, and so it might be desirable to hotplug
additional monitors at runtime for specific tasks. If doing that,
however, there is a need to remove the monitor when it is no longer
needed.

A use case for hotplugging a monitor can involve a user wishing to
spawn an ad hoc script that uses a temporary monitor. The script can
ask the management application to hotplug a monitor and pass back a
pre-opened FD using SCM_RIGHTS. In this case the lifetime of the
script is not tied to the management application and thus it is
desirable to have automatic cleanup when the script exits.

Allowing a client to run "object-del" against its own monitor adds
complex edge cases, as it would be desirable to send the QMP response
despite the monitor sending it being deleted. Doing "object-del" alone
will also result in orphaning a character device backend instance, as
there is no opportunity to run the companion "chardev-del" command.

A simpler way to ensure cleanup is to add the concept of auto-deleting
monitor objects. Specifically when the "CHR_EVENT_CLOSED" event is
emitted, the equivalent of "object-del" + "chardev-del" can be run
internally. Since the transient client has already droppped its
monitor connection, there is no synchronization to be concerned about
with sending QMP replies. There is still some internal synchronization
needed, however, between the character device event callback and the
bottom-half that runs the delete. There is a chance that an incoming
client connection may arise before the bottom-half runs, which has
to be checked. Once the monitor object is deleted, the event callback
is unregistered from the character device, eliminating any further
races before the character device is fully deleted.

This is implemented via a new "close-action=none|delete" property on
the 'monitor-qmp' object. This concept could be extended with further
actions in future, for example:

 * close-action=shutdown - graceful guest shutdown
 * close-action=terminate - immediate guest poweroff
 * close-action=stop - pause guest CPUs while the monitor is not
                       connected to any client

This is left as an exercise for future interested contributors.

Tested-by: Peter Krempa <pkrempa@redhat.com>
Reviewed-by: Marc-André Lureau <marcandre.lureau@redhat.com>
Acked-by: Markus Armbruster <armbru@redhat.com>
Signed-off-by: Daniel P. Berrangé <berrange@redhat.com>
Message-ID: <20260706135824.2623960-33-berrange@redhat.com>
[Commit message typos fixed]
Signed-off-by: Markus Armbruster <armbru@redhat.com>
2026-07-07 11:16:27 +02:00

306 lines
9.9 KiB
Python
Executable File

#!/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()