From fa73e6e4ca1a93c5bbf9d05fb2a25736ab810b35 Mon Sep 17 00:00:00 2001 From: John Snow Date: Mon, 31 Jan 2022 23:11:31 -0500 Subject: python/aqmp: Fix negotiation with pre-"oob" QEMU QEMU versions prior to the "oob" capability *also* can't accept the "enable" keyword argument at all. Fix the handshake process with older QEMU versions. Signed-off-by: John Snow Reviewed-by: Hanna Reitz Reviewed-by: Kevin Wolf Message-id: 20220201041134.1237016-2-jsnow@redhat.com Signed-off-by: John Snow --- python/qemu/aqmp/qmp_client.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) (limited to 'python/qemu') diff --git a/python/qemu/aqmp/qmp_client.py b/python/qemu/aqmp/qmp_client.py index f1a845cc82..90a8737f03 100644 --- a/python/qemu/aqmp/qmp_client.py +++ b/python/qemu/aqmp/qmp_client.py @@ -292,9 +292,9 @@ class QMPClient(AsyncProtocol[Message], Events): """ self.logger.debug("Negotiating capabilities ...") - arguments: Dict[str, List[str]] = {'enable': []} + arguments: Dict[str, List[str]] = {} if self._greeting and 'oob' in self._greeting.QMP.capabilities: - arguments['enable'].append('oob') + arguments.setdefault('enable', []).append('oob') msg = self.make_execute_msg('qmp_capabilities', arguments=arguments) # It's not safe to use execute() here, because the reader/writers -- cgit 1.4.1 From 50465f94d211beabfbfc80e4f85ec4fad0757570 Mon Sep 17 00:00:00 2001 From: John Snow Date: Mon, 31 Jan 2022 23:11:32 -0500 Subject: python/machine: raise VMLaunchFailure exception from launch() This allows us to pack in some extra information about the failure, which guarantees that if the caller did not *intentionally* cause a failure (by capturing this Exception), some pretty good clues will be printed at the bottom of the traceback information. This will help make failures in the event of a non-negative return code more obvious when they go unhandled; the current behavior in _post_shutdown() is to print a warning message only in the event of signal-based terminations (for negative return codes). (Note: In Python, catching BaseException instead of Exception catches a broader array of Exception events, including SystemExit and KeyboardInterrupt. We do not want to "wrap" such exceptions as a VMLaunchFailure, because that will 'downgrade' the exception from a BaseException to a regular Exception. We do, however, want to perform cleanup in either case, so catch on the broadest scope and wrap-and-re-raise only in the more targeted scope.) Signed-off-by: John Snow Reviewed-by: Hanna Reitz Reviewed-by: Kevin Wolf Message-id: 20220201041134.1237016-3-jsnow@redhat.com Signed-off-by: John Snow --- python/qemu/machine/machine.py | 45 ++++++++++++++++++++++++++----- tests/qemu-iotests/tests/mirror-top-perms | 3 +-- 2 files changed, 40 insertions(+), 8 deletions(-) (limited to 'python/qemu') diff --git a/python/qemu/machine/machine.py b/python/qemu/machine/machine.py index 67ab06ca2b..a5972fab4d 100644 --- a/python/qemu/machine/machine.py +++ b/python/qemu/machine/machine.py @@ -74,6 +74,35 @@ class QEMUMachineAddDeviceError(QEMUMachineError): """ +class VMLaunchFailure(QEMUMachineError): + """ + Exception raised when a VM launch was attempted, but failed. + """ + def __init__(self, exitcode: Optional[int], + command: str, output: Optional[str]): + super().__init__(exitcode, command, output) + self.exitcode = exitcode + self.command = command + self.output = output + + def __str__(self) -> str: + ret = '' + if self.__cause__ is not None: + name = type(self.__cause__).__name__ + reason = str(self.__cause__) + if reason: + ret += f"{name}: {reason}" + else: + ret += f"{name}" + ret += '\n' + + if self.exitcode is not None: + ret += f"\tExit code: {self.exitcode}\n" + ret += f"\tCommand: {self.command}\n" + ret += f"\tOutput: {self.output}\n" + return ret + + class AbnormalShutdown(QEMUMachineError): """ Exception raised when a graceful shutdown was requested, but not performed. @@ -397,7 +426,7 @@ class QEMUMachine: try: self._launch() - except: + except BaseException as exc: # We may have launched the process but it may # have exited before we could connect via QMP. # Assume the VM didn't launch or is exiting. @@ -408,11 +437,15 @@ class QEMUMachine: else: self._post_shutdown() - LOG.debug('Error launching VM') - if self._qemu_full_args: - LOG.debug('Command: %r', ' '.join(self._qemu_full_args)) - if self._iolog: - LOG.debug('Output: %r', self._iolog) + if isinstance(exc, Exception): + raise VMLaunchFailure( + exitcode=self.exitcode(), + command=' '.join(self._qemu_full_args), + output=self._iolog + ) from exc + + # Don't wrap 'BaseException'; doing so would downgrade + # that exception. However, we still want to clean up. raise def _launch(self) -> None: diff --git a/tests/qemu-iotests/tests/mirror-top-perms b/tests/qemu-iotests/tests/mirror-top-perms index 0a51a613f3..b5849978c4 100755 --- a/tests/qemu-iotests/tests/mirror-top-perms +++ b/tests/qemu-iotests/tests/mirror-top-perms @@ -21,7 +21,6 @@ import os -from qemu.aqmp import ConnectError from qemu.machine import machine from qemu.qmp import QMPConnectError @@ -107,7 +106,7 @@ class TestMirrorTopPerms(iotests.QMPTestCase): self.vm_b.launch() print('ERROR: VM B launched successfully, ' 'this should not have happened') - except (QMPConnectError, ConnectError): + except (QMPConnectError, machine.VMLaunchFailure): assert 'Is another process using the image' in self.vm_b.get_log() result = self.vm.qmp('block-job-cancel', -- cgit 1.4.1 From b0b662bb2b340d63529672b5bdae596a6243c4d0 Mon Sep 17 00:00:00 2001 From: John Snow Date: Mon, 31 Jan 2022 23:11:34 -0500 Subject: python/aqmp: add socket bind step to legacy.py The synchronous QMP library would bind to the server address during __init__(). The new library delays this to the accept() call, because binding occurs inside of the call to start_[unix_]server(), which is an async method -- so it cannot happen during __init__ anymore. Python 3.7+ adds the ability to create the server (and thus the bind() call) and begin the active listening in separate steps, but we don't have that functionality in 3.6, our current minimum. Therefore ... Add a temporary workaround that allows the synchronous version of the client to bind the socket in advance, guaranteeing that there will be a UNIX socket in the filesystem ready for the QEMU client to connect to without a race condition. (Yes, it's a bit ugly. Fixing it more nicely will have to wait until our minimum Python version is 3.7+.) Signed-off-by: John Snow Reviewed-by: Kevin Wolf Message-id: 20220201041134.1237016-5-jsnow@redhat.com Signed-off-by: John Snow --- python/qemu/aqmp/legacy.py | 3 +++ python/qemu/aqmp/protocol.py | 41 ++++++++++++++++++++++++++++++++++++++--- 2 files changed, 41 insertions(+), 3 deletions(-) (limited to 'python/qemu') diff --git a/python/qemu/aqmp/legacy.py b/python/qemu/aqmp/legacy.py index 0890f95b16..6baa5f3409 100644 --- a/python/qemu/aqmp/legacy.py +++ b/python/qemu/aqmp/legacy.py @@ -56,6 +56,9 @@ class QEMUMonitorProtocol(qemu.qmp.QEMUMonitorProtocol): self._address = address self._timeout: Optional[float] = None + if server: + self._aqmp._bind_hack(address) # pylint: disable=protected-access + _T = TypeVar('_T') def _sync( diff --git a/python/qemu/aqmp/protocol.py b/python/qemu/aqmp/protocol.py index 50e973c2f2..33358f5cd7 100644 --- a/python/qemu/aqmp/protocol.py +++ b/python/qemu/aqmp/protocol.py @@ -15,6 +15,7 @@ from asyncio import StreamReader, StreamWriter from enum import Enum from functools import wraps import logging +import socket from ssl import SSLContext from typing import ( Any, @@ -238,6 +239,9 @@ class AsyncProtocol(Generic[T]): self._runstate = Runstate.IDLE self._runstate_changed: Optional[asyncio.Event] = None + # Workaround for bind() + self._sock: Optional[socket.socket] = None + def __repr__(self) -> str: cls_name = type(self).__name__ tokens = [] @@ -427,6 +431,34 @@ class AsyncProtocol(Generic[T]): else: await self._do_connect(address, ssl) + def _bind_hack(self, address: Union[str, Tuple[str, int]]) -> None: + """ + Used to create a socket in advance of accept(). + + This is a workaround to ensure that we can guarantee timing of + precisely when a socket exists to avoid a connection attempt + bouncing off of nothing. + + Python 3.7+ adds a feature to separate the server creation and + listening phases instead, and should be used instead of this + hack. + """ + if isinstance(address, tuple): + family = socket.AF_INET + else: + family = socket.AF_UNIX + + sock = socket.socket(family, socket.SOCK_STREAM) + sock.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1) + + try: + sock.bind(address) + except: + sock.close() + raise + + self._sock = sock + @upper_half async def _do_accept(self, address: SocketAddrT, ssl: Optional[SSLContext] = None) -> None: @@ -464,24 +496,27 @@ class AsyncProtocol(Generic[T]): if isinstance(address, tuple): coro = asyncio.start_server( _client_connected_cb, - host=address[0], - port=address[1], + host=None if self._sock else address[0], + port=None if self._sock else address[1], ssl=ssl, backlog=1, limit=self._limit, + sock=self._sock, ) else: coro = asyncio.start_unix_server( _client_connected_cb, - path=address, + path=None if self._sock else address, ssl=ssl, backlog=1, limit=self._limit, + sock=self._sock, ) server = await coro # Starts listening await connected.wait() # Waits for the callback to fire (and finish) assert server is None + self._sock = None self.logger.debug("Connection accepted.") -- cgit 1.4.1