Skip to content
Closed
Show file tree
Hide file tree
Changes from 3 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ See Git checking messages for full history.
- Linux: add the XShmGetImage backend with automatic XGetImage fallback and explicit status reporting (#431)
- Windows: improve error checking and messages for Win32 API calls (#448)
- Mac: fix memory leak (#450, #453)
- improve multithreading: allow multiple threads to use the same MSS object, allow multiple MSS objects to concurrently take screenshots, and document multithreading guarantees (#446, #452)
- :heart: contributors: @jholveck

## 10.1.0 (2025-08-16)
Expand Down
5 changes: 5 additions & 0 deletions docs/source/examples.rst
Original file line number Diff line number Diff line change
Expand Up @@ -96,6 +96,11 @@ You can get the bytes of the PNG image:
Advanced
========

.. _custom_cls_image:

Custom ScreenShot Subclass
--------------------------

You can handle data using a custom class:

.. literalinclude:: examples/custom_cls_image.py
Expand Down
29 changes: 27 additions & 2 deletions docs/source/usage.rst
Original file line number Diff line number Diff line change
Expand Up @@ -54,12 +54,37 @@ This is a much better usage, memory efficient::
Also, it is a good thing to save the MSS instance inside an attribute of your class and calling it when needed.


Multithreading
==============

MSS is thread-safe and can be used from multiple threads.

**Sharing one MSS object**: You can use the same MSS object from multiple threads. Calls to
:py:meth:`mss.base.MSSBase.grab` (and other capture methods) are serialized automatically, meaning only one thread
will capture at a time. This may be relaxed in a future version if it can be done safely.

**Using separate MSS objects**: You can also create different MSS objects in different threads. Whether these run
concurrently or are serialized by the OS depends on the platform.

Custom :py:class:`mss.screenshot.ScreenShot` classes (see :ref:`custom_cls_image`) must **not** call
:py:meth:`mss.base.MSSBase.grab` in their constructor.

.. danger::
These guarantees do not apply to the obsolete Xlib backend, :py:mod:`mss.linux.xlib`. That backend is only used
if you specifically request it, so you won't be caught off-guard.

.. versionadded:: 10.2.0
Prior to this version, on some operating systems, the MSS object could only be used on the thread on which it was
created.

.. _backends:

Backends
--------
========

Some platforms have multiple ways to take screenshots. In MSS, these are known as *backends*. The :py:func:`mss` functions will normally autodetect which one is appropriate for your situation, but you can override this if you want. For instance, you may know that your specific situation requires a particular backend.
Some platforms have multiple ways to take screenshots. In MSS, these are known as *backends*. The :py:func:`mss`
functions will normally autodetect which one is appropriate for your situation, but you can override this if you want.
For instance, you may know that your specific situation requires a particular backend.

If you want to choose a particular backend, you can use the :py:attr:`backend` keyword to :py:func:`mss`::

Expand Down
32 changes: 24 additions & 8 deletions src/mss/base.py
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,10 @@
#: Global lock protecting access to platform screenshot calls.
#:
#: .. versionadded:: 6.0.0
#:
#: .. deprecated:: 10.2.0
#: The global lock is no longer used, and will be removed in a future release.
#: MSS objects now have their own locks, which are not publicly-accessible.
lock = Lock()

OPAQUE = 255
Expand All @@ -55,7 +59,7 @@ class MSSBase(metaclass=ABCMeta):
``compression_level``, ``display``, ``max_displays``, and ``with_cursor`` keyword arguments.
"""

__slots__ = {"_closed", "_monitors", "cls_image", "compression_level", "with_cursor"}
__slots__ = {"_closed", "_lock", "_monitors", "cls_image", "compression_level", "with_cursor"}

def __init__(
self,
Expand All @@ -79,8 +83,11 @@ def __init__(
#:
#: .. versionadded:: 8.0.0
self.with_cursor = with_cursor
# All attributes below are protected by self._lock.
self._lock = Lock()
self._monitors: Monitors = []
self._closed = False

# If there isn't a factory that removed the "backend" argument, make sure that it was set to "default".
# Factories that do backend-specific dispatch should remove that argument.
if backend != "default":
Expand All @@ -97,24 +104,33 @@ def __exit__(self, *_: object) -> None:

@abstractmethod
def _cursor_impl(self) -> ScreenShot | None:
"""Retrieve all cursor data. Pixels have to be RGB."""
"""Retrieve all cursor data. Pixels have to be RGB.

The object lock will be held when this method is called.
"""

@abstractmethod
def _grab_impl(self, monitor: Monitor, /) -> ScreenShot:
"""Retrieve all pixels from a monitor. Pixels have to be RGB.
That method has to be run using a threading lock.

The object lock will be held when this method is called.
"""

@abstractmethod
def _monitors_impl(self) -> None:
"""Get positions of monitors (has to be run using a threading lock).
"""Get positions of monitors.

It must populate self._monitors.

The object lock will be held when this method is called.
"""

def _close_impl(self) -> None: # noqa:B027
"""Clean up.

This will be called at most once.

The object lock will be held when this method is called.
"""
# It's not necessary for subclasses to implement this if they have nothing to clean up.

Expand All @@ -133,7 +149,7 @@ def close(self) -> None:
with mss.mss() as sct:
...
"""
with lock:
with self._lock:
if self._closed:
return
self._close_impl()
Expand Down Expand Up @@ -165,7 +181,7 @@ def grab(self, monitor: Monitor | tuple[int, int, int, int], /) -> ScreenShot:
msg = f"Region has zero or negative size: {monitor!r}"
raise ScreenShotError(msg)

with lock:
with self._lock:
screenshot = self._grab_impl(monitor)
if self.with_cursor and (cursor := self._cursor_impl()):
return self._merge(screenshot, cursor)
Expand All @@ -190,8 +206,8 @@ def monitors(self) -> Monitors:
- ``width``: the width
- ``height``: the height
"""
if not self._monitors:
with lock:
with self._lock:
if not self._monitors:
self._monitors_impl()

return self._monitors
Expand Down
147 changes: 83 additions & 64 deletions src/mss/linux/xlib.py
Original file line number Diff line number Diff line change
Expand Up @@ -32,10 +32,10 @@
create_string_buffer,
)
from ctypes.util import find_library
from threading import current_thread, local
from threading import Lock, current_thread, local
from typing import TYPE_CHECKING, Any

from mss.base import MSSBase, lock
from mss.base import MSSBase
from mss.exception import ScreenShotError

if TYPE_CHECKING: # pragma: nocover
Expand All @@ -45,6 +45,11 @@
__all__ = ("MSS",)


# Global lock protecting access to Xlib calls.
# A per-object lock must not be acquired while this is held. It is safe to acquire this global lock while a
# per-object lock is currently held.
_lock = Lock()

X_FIRST_EXTENSION_OPCODE = 128
PLAINMASK = 0x00FFFFFF
ZPIXMAP = 2
Expand Down Expand Up @@ -391,6 +396,13 @@ class MSS(MSSBase):
taken from the environment variable :envvar:`DISPLAY`.
:type display: str | bytes | None

.. danger::
The Xlib backend does not have the same multithreading
guarantees as the rest of MSS. Specifically, the object may
only be used on the thread in which it was created.
Additionally, while rare, using multiple MSS objects in
different threads simultaneously may still cause problems.

.. seealso::
:py:class:`mss.base.MSSBase`
Lists other parameters.
Expand Down Expand Up @@ -444,26 +456,27 @@ def __init__(self, /, **kwargs: Any) -> None:

self._set_cfunctions()

# Install the error handler to prevent interpreter crashes: any error will raise a ScreenShotError exception
self._handles.original_error_handler = self.xlib.XSetErrorHandler(_error_handler)
with _lock:
# Install the error handler to prevent interpreter crashes: any error will raise a ScreenShotError
# exception
self._handles.original_error_handler = self.xlib.XSetErrorHandler(_error_handler)

self._handles.display = self.xlib.XOpenDisplay(display)
if not self._handles.display:
msg = f"Unable to open display: {display!r}."
raise ScreenShotError(msg)
self._handles.display = self.xlib.XOpenDisplay(display)
if not self._handles.display:
msg = f"Unable to open display: {display!r}."
raise ScreenShotError(msg)

self._handles.drawable = self._handles.root = self.xlib.XDefaultRootWindow(self._handles.display)

if not self._is_extension_enabled("RANDR"):
msg = "Xrandr not enabled."
raise ScreenShotError(msg)

self._handles.drawable = self._handles.root = self.xlib.XDefaultRootWindow(self._handles.display)

def _close_impl(self) -> None:
# Clean-up
if self._handles.display:
# We don't grab the lock, since MSSBase.close is holding
# it for us.
self.xlib.XCloseDisplay(self._handles.display)
with _lock:
self.xlib.XCloseDisplay(self._handles.display)
self._handles.display = None
self._handles.drawable = None
self._handles.root = None
Expand All @@ -475,7 +488,8 @@ def _close_impl(self) -> None:
# Interesting technical stuff can be found here:
# https://core.tcl-lang.org/tk/file?name=generic/tkError.c&ci=a527ef995862cb50
# https://github.com/tcltk/tk/blob/b9cdafd83fe77499ff47fa373ce037aff3ae286a/generic/tkError.c
self.xlib.XSetErrorHandler(self._handles.original_error_handler)
with _lock:
self.xlib.XSetErrorHandler(self._handles.original_error_handler)
self._handles.original_error_handler = None

# Also empty the error dict
Expand All @@ -488,7 +502,7 @@ def _is_extension_enabled(self, name: str, /) -> bool:
first_error_return = c_int()

try:
with lock:
with _lock:
self.xlib.XQueryExtension(
self._handles.display,
name.encode("latin1"),
Expand Down Expand Up @@ -519,59 +533,62 @@ def _monitors_impl(self) -> None:
int_ = int
xrandr = self.xrandr

xrandr_major = c_int(0)
xrandr_minor = c_int(0)
xrandr.XRRQueryVersion(display, xrandr_major, xrandr_minor)

# All monitors
gwa = XWindowAttributes()
self.xlib.XGetWindowAttributes(display, self._handles.root, byref(gwa))
self._monitors.append(
{"left": int_(gwa.x), "top": int_(gwa.y), "width": int_(gwa.width), "height": int_(gwa.height)},
)

# Each monitor
# A simple benchmark calling 10 times those 2 functions:
# XRRGetScreenResources(): 0.1755971429956844 s
# XRRGetScreenResourcesCurrent(): 0.0039125580078689 s
# The second is faster by a factor of 44! So try to use it first.
# It doesn't query the monitors for updated information, but it does require the server to support
# RANDR 1.3. We also make sure the client supports 1.3, by checking for the presence of the function.
if hasattr(xrandr, "XRRGetScreenResourcesCurrent") and (xrandr_major.value, xrandr_minor.value) >= (1, 3):
mon = xrandr.XRRGetScreenResourcesCurrent(display, self._handles.drawable).contents
else:
mon = xrandr.XRRGetScreenResources(display, self._handles.drawable).contents

crtcs = mon.crtcs
for idx in range(mon.ncrtc):
crtc = xrandr.XRRGetCrtcInfo(display, mon, crtcs[idx]).contents
if crtc.noutput == 0:
xrandr.XRRFreeCrtcInfo(crtc)
continue
with _lock:
xrandr_major = c_int(0)
xrandr_minor = c_int(0)
xrandr.XRRQueryVersion(display, xrandr_major, xrandr_minor)

# All monitors
gwa = XWindowAttributes()
self.xlib.XGetWindowAttributes(display, self._handles.root, byref(gwa))
self._monitors.append(
{
"left": int_(crtc.x),
"top": int_(crtc.y),
"width": int_(crtc.width),
"height": int_(crtc.height),
},
{"left": int_(gwa.x), "top": int_(gwa.y), "width": int_(gwa.width), "height": int_(gwa.height)},
)
xrandr.XRRFreeCrtcInfo(crtc)
xrandr.XRRFreeScreenResources(mon)

# Each monitor
# A simple benchmark calling 10 times those 2 functions:
# XRRGetScreenResources(): 0.1755971429956844 s
# XRRGetScreenResourcesCurrent(): 0.0039125580078689 s
# The second is faster by a factor of 44! So try to use it first.
# It doesn't query the monitors for updated information, but it does require the server to support RANDR
# 1.3. We also make sure the client supports 1.3, by checking for the presence of the function.
if hasattr(xrandr, "XRRGetScreenResourcesCurrent") and (xrandr_major.value, xrandr_minor.value) >= (1, 3):
mon = xrandr.XRRGetScreenResourcesCurrent(display, self._handles.drawable).contents
else:
mon = xrandr.XRRGetScreenResources(display, self._handles.drawable).contents

crtcs = mon.crtcs
for idx in range(mon.ncrtc):
crtc = xrandr.XRRGetCrtcInfo(display, mon, crtcs[idx]).contents
if crtc.noutput == 0:
xrandr.XRRFreeCrtcInfo(crtc)
continue

self._monitors.append(
{
"left": int_(crtc.x),
"top": int_(crtc.y),
"width": int_(crtc.width),
"height": int_(crtc.height),
},
)
xrandr.XRRFreeCrtcInfo(crtc)
xrandr.XRRFreeScreenResources(mon)

def _grab_impl(self, monitor: Monitor, /) -> ScreenShot:
"""Retrieve all pixels from a monitor. Pixels have to be RGB."""
ximage = self.xlib.XGetImage(
self._handles.display,
self._handles.drawable,
monitor["left"],
monitor["top"],
monitor["width"],
monitor["height"],
PLAINMASK,
ZPIXMAP,
)

with _lock:
ximage = self.xlib.XGetImage(
self._handles.display,
self._handles.drawable,
monitor["left"],
monitor["top"],
monitor["width"],
monitor["height"],
PLAINMASK,
ZPIXMAP,
)

try:
bits_per_pixel = ximage.contents.bits_per_pixel
Expand All @@ -586,14 +603,16 @@ def _grab_impl(self, monitor: Monitor, /) -> ScreenShot:
data = bytearray(raw_data.contents)
finally:
# Free
self.xlib.XDestroyImage(ximage)
with _lock:
self.xlib.XDestroyImage(ximage)

return self.cls_image(data, monitor)

def _cursor_impl(self) -> ScreenShot:
"""Retrieve all cursor data. Pixels have to be RGB."""
# Read data of cursor/mouse-pointer
ximage = self.xfixes.XFixesGetCursorImage(self._handles.display)
with _lock:
ximage = self.xfixes.XFixesGetCursorImage(self._handles.display)
if not (ximage and ximage.contents):
msg = "Cannot read XFixesGetCursorImage()"
raise ScreenShotError(msg)
Expand Down
Loading