Skip to content
Open
Show file tree
Hide file tree
Changes from all 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
2 changes: 2 additions & 0 deletions docs/source/release-history/v11.0.0.md
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,8 @@ The MSS project has chosen to end support for Python 3.9, in order to focus our

Improved error handling when interacting with Win32 API, which will improve diagnostics of issues.

Device contexts are now acquired and released within each `grab()` call, allowing monitor enumeration to work even when `GetWindowDC(0)` fails (#509).

### General Improvements

The MSS context object will now always surface inner exceptions, even if `__exit__` may also generate an exception during tear-down.
108 changes: 52 additions & 56 deletions src/mss/windows/gdi.py
Original file line number Diff line number Diff line change
Expand Up @@ -205,9 +205,7 @@ class MSSImplGdi(MSSImplementation):
"_dib",
"_dib_array",
"_dib_bits",
"_memdc",
"_region_width_height",
"_srcdc",
"gdi32",
"user32",
}
Expand All @@ -226,8 +224,6 @@ def __init__(self) -> None:
self._dib: HBITMAP | None = None
self._dib_bits: LPVOID = LPVOID() # Pointer to DIB pixel data
self._dib_array: ctypes.Array[ctypes.c_char] | None = None # Cached array view of DIB memory
self._srcdc = self.user32.GetWindowDC(0)
self._memdc = self.gdi32.CreateCompatibleDC(self._srcdc)

bmi = BITMAPINFO()
bmi.bmiHeader.biSize = ctypes.sizeof(BITMAPINFOHEADER)
Expand All @@ -243,19 +239,10 @@ def __init__(self) -> None:
self._bmi = bmi

def close(self) -> None:
# Clean-up
if self._dib:
self.gdi32.DeleteObject(self._dib)
self._dib = None

if self._memdc:
self.gdi32.DeleteDC(self._memdc)
self._memdc = None

if self._srcdc:
self.user32.ReleaseDC(0, self._srcdc)
self._srcdc = None

def _set_cfunctions(self) -> None:
"""Set all ctypes functions and attach them to attributes."""
cfactory = self._cfactory
Expand Down Expand Up @@ -357,57 +344,66 @@ def callback(hmonitor: HMONITOR, _data: HDC, rect: LPRECT, _dc: LPARAM) -> bool:
def grab(self, monitor: Monitor, /) -> bytearray:
"""Retrieve all pixels from a monitor using CreateDIBSection.

CreateDIBSection creates a DIB with system-managed memory backing,
allowing BitBlt to write directly to memory we can read. This eliminates
the need for a separate GetDIBits call.
Device contexts (srcdc / memdc) are acquired and released within each
call. This avoids holding GDI resources across threads and allows
``MSS()`` construction to succeed even when ``GetWindowDC(0)`` would
fail (locked screen, UAC, RDP). See issue #509.

Note on biHeight: A bottom-up DIB is specified by setting the height to a
positive number, while a top-down DIB is specified by setting the height
to a negative number. We use negative height for top-down orientation.
https://learn.microsoft.com/en-us/windows/win32/api/wingdi/ns-wingdi-bitmapinfoheader
https://learn.microsoft.com/en-us/windows/win32/api/wingdi/nf-wingdi-createdibsection
"""
srcdc, memdc = self._srcdc, self._memdc
gdi = self.gdi32
width, height = monitor["width"], monitor["height"]

if self._region_width_height != (width, height):
self._region_width_height = (width, height)
self._bmi.bmiHeader.biWidth = width
self._bmi.bmiHeader.biHeight = -height # Negative for top-down DIB

if self._dib:
gdi.DeleteObject(self._dib)
self._dib = None

# CreateDIBSection creates the DIB and returns a pointer to the pixel data
self._dib_bits = LPVOID()
self._dib = gdi.CreateDIBSection(
memdc,
self._bmi,
DIB_RGB_COLORS,
ctypes.byref(self._dib_bits),
None, # hSection = NULL (system allocates memory)
0, # offset = 0
)
gdi.SelectObject(memdc, self._dib)

# Create a ctypes array type that maps directly to the DIB memory.
# This avoids the overhead of ctypes.string_at() creating an intermediate bytes object.
size = width * height * 4
array_type = ctypes.c_char * size
self._dib_array = ctypes.cast(self._dib_bits, POINTER(array_type)).contents

# BitBlt copies screen content directly into the DIB's memory
gdi.BitBlt(memdc, 0, 0, width, height, srcdc, monitor["left"], monitor["top"], SRCCOPY | CAPTUREBLT)

# Flush GDI operations to ensure DIB memory is fully updated before reading.
# This ensures the BitBlt has completed before we access the memory.
gdi.GdiFlush()

# Read directly from DIB memory via the cached array view
assert self._dib_array is not None # noqa: S101 for type checker
return bytearray(self._dib_array)
srcdc = self.user32.GetWindowDC(0)
try:
memdc = gdi.CreateCompatibleDC(srcdc)
try:
width, height = monitor["width"], monitor["height"]

if self._region_width_height != (width, height):
self._region_width_height = (width, height)
self._bmi.bmiHeader.biWidth = width
self._bmi.bmiHeader.biHeight = -height # Negative for top-down DIB

if self._dib:
gdi.DeleteObject(self._dib)
self._dib = None

# CreateDIBSection creates the DIB and returns a pointer to the pixel data
self._dib_bits = LPVOID()
self._dib = gdi.CreateDIBSection(
memdc,
self._bmi,
DIB_RGB_COLORS,
ctypes.byref(self._dib_bits),
None, # hSection = NULL (system allocates memory)
0, # offset = 0
)

# Create a ctypes array type that maps directly to the DIB memory.
# This avoids the overhead of ctypes.string_at() creating an intermediate bytes object.
size = width * height * 4
array_type = ctypes.c_char * size
self._dib_array = ctypes.cast(self._dib_bits, POINTER(array_type)).contents

# Select the cached DIB into the per-call memdc so BitBlt writes into it.
gdi.SelectObject(memdc, self._dib)

# BitBlt copies screen content directly into the DIB's memory
gdi.BitBlt(memdc, 0, 0, width, height, srcdc, monitor["left"], monitor["top"], SRCCOPY | CAPTUREBLT)

# Flush GDI operations to ensure DIB memory is fully updated before reading.
gdi.GdiFlush()

# Read directly from DIB memory via the cached array view
assert self._dib_array is not None # noqa: S101 for type checker
return bytearray(self._dib_array)
finally:
gdi.DeleteDC(memdc)
finally:
self.user32.ReleaseDC(0, srcdc)

def cursor(self) -> None:
"""Retrieve all cursor data. Pixels have to be RGB."""
Expand Down
44 changes: 24 additions & 20 deletions src/tests/bench_grab_windows.py
Original file line number Diff line number Diff line change
Expand Up @@ -109,36 +109,40 @@ def benchmark_raw_bitblt() -> None:
srccopy = 0x00CC0020
captureblt = 0x40000000

user32 = ctypes.WinDLL("user32", use_last_error=True)

with mss.MSS() as sct:
monitor = sct.monitors[1]
width, height = monitor["width"], monitor["height"]
left, top = monitor["left"], monitor["top"]

# Force region setup
sct.grab(monitor)

assert isinstance(sct._impl, mss.windows.gdi.MSSImplGdi)
srcdc = sct._impl._srcdc
memdc = sct._impl._memdc
# Acquire DCs directly for raw benchmarking (the impl no longer
# holds them as instance state — they are per-grab now).
srcdc = user32.GetWindowDC(0)
memdc = gdi32.CreateCompatibleDC(srcdc)

print(f"Raw BitBlt benchmark ({width}x{height})")
print("=" * 50)

# Test with CAPTUREBLT
start = perf_counter()
for _ in range(ITERATIONS):
bitblt(memdc, 0, 0, width, height, srcdc, left, top, srccopy | captureblt)
gdiflush()
elapsed = perf_counter() - start
print(f"With CAPTUREBLT: {elapsed / ITERATIONS * 1000:.2f}ms ({ITERATIONS / elapsed:.1f} FPS)")
try:
# Test with CAPTUREBLT
start = perf_counter()
for _ in range(ITERATIONS):
bitblt(memdc, 0, 0, width, height, srcdc, left, top, srccopy | captureblt)
gdiflush()
elapsed = perf_counter() - start
print(f"With CAPTUREBLT: {elapsed / ITERATIONS * 1000:.2f}ms ({ITERATIONS / elapsed:.1f} FPS)")

# Test without CAPTUREBLT
start = perf_counter()
for _ in range(ITERATIONS):
bitblt(memdc, 0, 0, width, height, srcdc, left, top, srccopy)
gdiflush()
elapsed = perf_counter() - start
print(f"Without CAPTUREBLT: {elapsed / ITERATIONS * 1000:.2f}ms ({ITERATIONS / elapsed:.1f} FPS)")
# Test without CAPTUREBLT
start = perf_counter()
for _ in range(ITERATIONS):
bitblt(memdc, 0, 0, width, height, srcdc, left, top, srccopy)
gdiflush()
elapsed = perf_counter() - start
print(f"Without CAPTUREBLT: {elapsed / ITERATIONS * 1000:.2f}ms ({ITERATIONS / elapsed:.1f} FPS)")
finally:
gdi32.DeleteDC(memdc)
user32.ReleaseDC(0, srcdc)


def analyze_frame_timing() -> None:
Expand Down
26 changes: 26 additions & 0 deletions src/tests/test_windows.py
Original file line number Diff line number Diff line change
Expand Up @@ -145,6 +145,32 @@ def test_region_not_caching() -> None:
assert dib1 != dib2


def test_monitors_work_when_getwindowdc_fails() -> None:
"""Regression test for issue #509.

``GetWindowDC(0)`` can fail (locked screen, UAC, RDP, GDI pressure).
Enumerating monitors must still work because it only needs
``EnumDisplayMonitors`` / ``GetMonitorInfoW``.
"""
with mss.MSS() as sct:
impl = sct._impl
assert isinstance(impl, MSSImplGdi)

# Simulate GetWindowDC failing — grab() should raise, but
# monitors must remain accessible.
original = impl.user32.GetWindowDC
impl.user32.GetWindowDC = lambda _hwnd: 0 # type: ignore[attr-defined]
try:
monitors = sct.monitors
assert len(monitors) >= 1
assert "width" in monitors[0]

with pytest.raises(ScreenShotError):
sct.grab(monitors[1])
finally:
impl.user32.GetWindowDC = original # type: ignore[attr-defined]


def run_child_thread(loops: int) -> None:
for _ in range(loops):
with mss.MSS() as sct: # New sct for every loop
Expand Down
Loading