"""XCB backend using MIT-SHM XShmGetImage with automatic fallback. This is the fastest Linux backend available, and will work in most common cases. However, it will not work over remote X connections, such as over ssh. This implementation prefers shared-memory captures for performance and will fall back to XGetImage when the MIT-SHM extension is unavailable or fails at runtime. The fallback reason is exposed via ``performance_status`` to aid debugging. .. versionadded:: 10.2.0 """ from __future__ import annotations import enum import os from mmap import PROT_READ, mmap # type: ignore[attr-defined] from typing import TYPE_CHECKING, Any from mss.exception import ScreenShotError from mss.linux import xcb from mss.linux.base import ALL_PLANES, MSSImplXCBBase from mss.linux.xcbhelpers import LIB, XProtoError if TYPE_CHECKING: from mss.models import Monitor __all__ = () class ShmStatus(enum.Enum): """Availability of the MIT-SHM extension for this backend.""" UNKNOWN = enum.auto() # Constructor says SHM *should* work, but we haven't seen a real GetImage succeed yet. AVAILABLE = enum.auto() # We've successfully used XShmGetImage at least once. UNAVAILABLE = enum.auto() # We know SHM GetImage is unusable; always use XGetImage. class MSSImplXShmGetImage(MSSImplXCBBase): """XCB backend using XShmGetImage with an automatic XGetImage fallback. .. seealso:: :py:class:`mss.linux.base.MSSImplXCBBase` Lists constructor parameters. """ def __init__(self, *, display: str | bytes | None = None, with_cursor: bool = False) -> None: super().__init__(display=display, with_cursor=with_cursor) # These are the objects we need to clean up when we shut down. They are created in _setup_shm. self._memfd: int | None = None self._buf: mmap | None = None self._shmseg: xcb.ShmSeg | None = None # Rather than trying to track the shm_status, we may be able to raise an exception in __init__ if XShmGetImage # isn't available. The factory in linux/__init__.py could then catch that and switch to XGetImage. # The conditions under which the attach will succeed but the xcb_shm_get_image will fail are extremely # rare, and I haven't yet found any that also will work with xcb_get_image. #: Whether we can use the MIT-SHM extensions for this connection. #: This will not be ``AVAILABLE`` until at least one capture has succeeded. #: It may be set to ``UNAVAILABLE`` sooner. self.shm_status: ShmStatus = self._setup_shm() def _shm_report_issue(self, msg: str, *args: Any) -> None: """Debugging hook for troubleshooting MIT-SHM issues. This will be called whenever MIT-SHM is disabled. The optional arguments are not well-defined; exceptions are common. """ full_msg = msg if args: full_msg += " | " + ", ".join(str(arg) for arg in args) self.performance_status.append(full_msg) def _setup_shm(self) -> ShmStatus: # noqa: PLR0911 assert self.conn is not None # noqa: S101 try: shm_ext_data = xcb.get_extension_data(self.conn, LIB.shm_id) if not shm_ext_data.present: self._shm_report_issue("MIT-SHM extension not present") return ShmStatus.UNAVAILABLE # We use the FD-based version of ShmGetImage, so we require the extension to be at least 1.2. shm_version_data = xcb.shm_query_version(self.conn) shm_version = (shm_version_data.major_version, shm_version_data.minor_version) if shm_version < (1, 2): self._shm_report_issue("MIT-SHM version too old", shm_version) return ShmStatus.UNAVAILABLE # We allocate something large enough for the root, so we don't have to reallocate each time the window is # resized. self._bufsize = self.pref_screen.width_in_pixels * self.pref_screen.height_in_pixels * 4 if not hasattr(os, "memfd_create"): self._shm_report_issue("os.memfd_create not available") return ShmStatus.UNAVAILABLE try: self._memfd = os.memfd_create("mss-shm-buf", flags=os.MFD_CLOEXEC) # type: ignore[attr-defined] except OSError as e: return self._shm_unavailable("memfd_create failed", e) os.ftruncate(self._memfd, self._bufsize) try: self._buf = mmap(self._memfd, self._bufsize, prot=PROT_READ) # type: ignore[call-arg] except OSError as e: return self._shm_unavailable("mmap failed", e) self._shmseg = xcb.ShmSeg(xcb.generate_id(self.conn).value) try: # This will normally be what raises an exception if you're on a remote connection. # XCB will close _memfd, on success or on failure. try: xcb.shm_attach_fd(self.conn, self._shmseg, self._memfd, read_only=False) finally: self._memfd = None except xcb.XError as e: return self._shm_unavailable("Cannot attach MIT-SHM segment", e) except Exception: self._shutdown_shm() raise return ShmStatus.UNKNOWN def _shm_unavailable(self, msg: str, exc: Exception) -> ShmStatus: self._shm_report_issue(msg, exc) self._shutdown_shm() return ShmStatus.UNAVAILABLE def close(self) -> None: self._shutdown_shm() super().close() def _shutdown_shm(self) -> None: # It would be nice to also try to tell the server to detach the shmseg, but we might be in an error path # and don't know if that's possible. It's not like we'll leak a lot of them on the same connection anyway. # This can be called in the path of partial initialization. if self._buf is not None: self._buf.close() self._buf = None if self._memfd is not None: os.close(self._memfd) self._memfd = None def _grab_xshmgetimage(self, monitor: Monitor) -> bytearray: if self.conn is None: msg = "Cannot take screenshot while the connection is closed" raise ScreenShotError(msg) assert self._buf is not None # noqa: S101 assert self._shmseg is not None # noqa: S101 required_size = monitor["width"] * monitor["height"] * 4 if required_size > self._bufsize: # This is temporary. The permanent fix will depend on how # issue https://github.com/BoboTiG/python-mss/issues/432 is resolved. msg = ( "Requested capture size exceeds the allocated buffer. If you have resized the screen, " "please recreate your MSS object." ) raise ScreenShotError(msg) img_reply = xcb.shm_get_image( self.conn, self.drawable, monitor["left"], monitor["top"], monitor["width"], monitor["height"], ALL_PLANES, xcb.ImageFormat.ZPixmap, self._shmseg, 0, ) if img_reply.depth != self.drawable_depth or img_reply.visual != self.drawable_visual_id: # This should never happen; a window can't change its visual. msg = ( "Server returned an image with a depth or visual different than it initially reported: " f"expected {self.drawable_depth},{hex(self.drawable_visual_id.value)}, " f"got {img_reply.depth},{hex(img_reply.visual.value)}" ) raise ScreenShotError(msg) # Snapshot the buffer into new bytearray. new_size = monitor["width"] * monitor["height"] * 4 # Slicing the memoryview creates a new memoryview that points to the relevant subregion. Making this and then # copying it into a fresh bytearray is much faster than slicing the mmap object. Make sure we don't hold an # open memoryview if an exception happens, since that will prevent us from closing self._buf during the stack # unwind. with memoryview(self._buf) as img_mv: return bytearray(img_mv[:new_size]) def grab(self, monitor: Monitor) -> bytearray: """Retrieve all pixels from a monitor. Pixels have to be RGBX.""" if self.shm_status == ShmStatus.UNAVAILABLE: return super()._grab_xgetimage(monitor) # The usual path is just the next few lines. try: rv = self._grab_xshmgetimage(monitor) if self.shm_status != ShmStatus.AVAILABLE: self.shm_status = ShmStatus.AVAILABLE self.performance_status.append("MIT-SHM is working correctly.") except XProtoError as e: if self.shm_status != ShmStatus.UNKNOWN: # We know XShmGetImage works, because it worked earlier. Reraise the error. raise # Should we engage the fallback path? In almost all cases, if XShmGetImage failed at this stage (after # all our testing in __init__), XGetImage will also fail. This could mean that the user sent an # out-of-bounds request. In more exotic situations, some rare X servers disallow screen capture # altogether: security-hardened servers, for instance, or some XPrint servers. But let's make sure, by # testing the same request through XGetImage. try: rv = super()._grab_xgetimage(monitor) except XProtoError: # noqa: TRY203 # The XGetImage also failed, so we don't know anything about whether XShmGetImage is usable. Maybe # the user sent an out-of-bounds request. Maybe it's a security-hardened server. We're not sure what # the problem is. So, if XGetImage failed, we re-raise that error (the one from XShmGetImage will be # attached as __context__), but we won't update the shm_status yet. (Technically, our except:raise # clause here is redundant; it's just for clarity, to hold this comment.) raise # Using XShmGetImage failed, and using XGetImage worked. Use XGetImage in the future. self._shm_report_issue("MIT-SHM GetImage failed", e) self.shm_status = ShmStatus.UNAVAILABLE self._shutdown_shm() return rv