Skip to content

fix(windows): Lazy-init desktop and memory DCs#516

Open
mxschmitt wants to merge 2 commits intoBoboTiG:mainfrom
mxschmitt:fix/lazy-init-windows-dcs
Open

fix(windows): Lazy-init desktop and memory DCs#516
mxschmitt wants to merge 2 commits intoBoboTiG:mainfrom
mxschmitt:fix/lazy-init-windows-dcs

Conversation

@mxschmitt
Copy link
Copy Markdown

@mxschmitt mxschmitt commented Apr 27, 2026

Fixes #509.

Problem

MSSImplGdi.__init__ unconditionally calls GetWindowDC(0) and CreateCompatibleDC at src/mss/windows/gdi.py:208-209. Both can fail with WinError 5: Access is denied under:

  • Locked screen / session switch
  • UAC prompt in the foreground
  • RDP disconnect
  • GDI handle pressure (the process has approached the per-process limit)

As a result, even read-only use like with mss.MSS() as sct: sct.monitors crashes in these environments — despite monitors() only needing EnumDisplayMonitors / GetMonitorInfoW, neither of which requires a desktop DC.

Fix

Defer GetWindowDC(0) and CreateCompatibleDC to the first grab() call via a new _ensure_dcs() helper. close() already guards on None, so no cleanup changes are needed. The change is backwards compatible — grab() still works identically; the DCs are just created on demand.

Test plan

  • Added test_monitors_does_not_call_getwindowdc in src/tests/test_windows.py that wraps GetWindowDC with an assertion so any call during sct.monitors fails the test.
  • ruff check passes
  • ruff format --check passes
  • Existing non-Windows tests still pass locally on macOS (77 passed, 52 skipped — Windows/Linux-specific).
  • Existing Windows tests still pass in CI (maintainer to verify).

@mxschmitt mxschmitt marked this pull request as ready for review April 27, 2026 17:03
Comment thread src/mss/windows/gdi.py Outdated
Comment thread src/mss/windows/gdi.py Outdated
@BoboTiG
Copy link
Copy Markdown
Owner

BoboTiG commented Apr 27, 2026

Hm one the test failure (https://github.com/BoboTiG/python-mss/actions/runs/25008585510/job/73238170128?pr=516#step:6:268) seems not OK, but it also seems to be unrelated to your changes. Or at least, your changes show an unexpected usage of MSS that could occur in other projects.

@halldorfannar how does it sound to you?

@mxschmitt
Copy link
Copy Markdown
Author

I don't have enough background in this area but I feel like the per-thread DC via threading.local sounds reasonable?

Investigation report: https://gist.github.com/mxschmitt/cfa1b5f7ae60265450f5f45c5edeaa37#failure-23-test_same_object_multiple_threads--releasedc-returns-0

@halldorfannar
Copy link
Copy Markdown
Contributor

Hm one the test failure (https://github.com/BoboTiG/python-mss/actions/runs/25008585510/job/73238170128?pr=516#step:6:268) seems not OK, but it also seems to be unrelated to your changes. Or at least, your changes show an unexpected usage of MSS that could occur in other projects.

@halldorfannar how does it sound to you?

Let me take a look!

@halldorfannar
Copy link
Copy Markdown
Contributor

halldorfannar commented Apr 27, 2026

Instead of delaying the acquisition of DCs we should try to acquire them as before (inside __init__) but fail gracefully (with a warning) when this doesn't succeed. This will allow monitors to be enumerated. Then if user calls grab we will have to attempt acquiring the DCs again BUT we must also release them after every call. This is simply because Windows requires the same thread to be used for acquisition and release. Therefore the library would go into "inefficient mode" but still work reliably. The warning mentioned above could warn the user about this situation.

@BoboTiG
Copy link
Copy Markdown
Owner

BoboTiG commented Apr 27, 2026

To iterate on your proposal @halldorfannar: let's use a try/except block in __init__, and add a simple check for if not self._srcdc at the top of grab(). If we failed to get something at init, then it might be expected that it doesn't work. We could raise a RuntimeError() in that case.

@halldorfannar
Copy link
Copy Markdown
Contributor

@BoboTiG yes, this will work. A try/except block around the acquisition of both DCs would be required and inside the except we would set both to None. Then raising a runtime error from grab would be valid. I would still advocate for a warning in the except case.

@BoboTiG
Copy link
Copy Markdown
Owner

BoboTiG commented Apr 27, 2026

(Unrelated to that PR)

And about that kind of error:

winerror   = OSError(22, 'The operation completed successfully.', None, 0)

It seems to take the path https://github.com/BoboTiG/python-mss/blob/v10.2.0/src/mss/windows/gdi.py#L123-L127 if I read it correctly.

Should we improve our logic there?

@halldorfannar
Copy link
Copy Markdown
Contributor

(Unrelated to that PR)

And about that kind of error:

winerror   = OSError(22, 'The operation completed successfully.', None, 0)

It seems to take the path https://github.com/BoboTiG/python-mss/blob/v10.2.0/src/mss/windows/gdi.py#L123-L127 if I read it correctly.

Should we improve our logic there?

Isn't this error due to the potential thread mismatch I was talking about? Do you want the error reporting improved? Maybe I'm just not understanding what you are trying to tell me 😄

@BoboTiG
Copy link
Copy Markdown
Owner

BoboTiG commented Apr 27, 2026

Maybe it's not clear enough in my head too, haha!

We check for if winerror.winerror == 0, but in this case (whatever the root cause) the call was a success but with winerror.winerror set to 0. So we report a failure but as the message states, it was a success: The operation completed successfully.

I'm trying to get more information if we should enhance our error reporting, or if we just ignore it and move forward.

@jholveck
Copy link
Copy Markdown
Contributor

I'll readily admit that I don't really understand the proposed change. Is there consensus, or are the guys who actually know Windows (i.e., everybody but me) still working it out?

The thread mismatch does concern me somewhat. The MSS docs currently state that you can pass around MSS objects all you want between threads, and there's an implication that you can close them in a different thread than you opened them.

I do see that the GetDC and ReleaseDC need to be paired on the same thread, per the docs. I'm not seeing anything about CreateCompatibleDC and DeleteDC, though. Do those need to be paired?

If not, we could keep memdc for the lifetime of the MSS object, and do the GetWindowDC and ReleaseDC on srcdc in grab. I'm guessing those are cheap operations? Of course, we'd want to measure it, but this might be a reasonable solution.

@jholveck
Copy link
Copy Markdown
Contributor

Additionally: I see that we delete self._dib while it's still selected into self._memdc, in grab (when resizing) and in close. The docs seem to say we shouldn't do that.

A fresh memory DC, I'm led to understand, has a default 1x1 bitmap selected into it. We'll get that back when we select self._dib into self._memdc. I suggest that we save that (as self._placeholder_dib or self._old_dib or whatever) when we call SelectObject. Then, whenever we're about to delete self._dib, we select that default dib into memdc first.

But again, I may be completely wrong here. I was alerted to this issue by an AI, and so before acting on that, it should be considered by somebody who knows Windows GDI better than I.

@halldorfannar
Copy link
Copy Markdown
Contributor

halldorfannar commented Apr 28, 2026

Maybe it's not clear enough in my head too, haha!

We check for if winerror.winerror == 0, but in this case (whatever the root cause) the call was a success but with winerror.winerror set to 0. So we report a failure but as the message states, it was a success: The operation completed successfully.

I'm trying to get more information if we should enhance our error reporting, or if we just ignore it and move forward.

OK, I think I understand our misunderstanding now. The call did fail, it was just that Windows didn't set last error. I will improve the messaging. It's confusing, I see that now. I can just do that as a separate PR.

The error we are seeing here has to do with thread requirements when it comes to creating and releasing DCs. This is why the current PR as written is not safe.

@halldorfannar
Copy link
Copy Markdown
Contributor

For the overall PR I want to perform some benchmarks to compare a few different strategies for allocating/freeing the necessary resources. That will inform us on how we can allow monitor enumeration (as this PR does) while still safely acquiring/releasing the Windows objects.

@halldorfannar
Copy link
Copy Markdown
Contributor

The error handling improvements are in #519 - turns out we had three problems 😅 Once merged you will have a much cleaner error log from this PR.

@BoboTiG
Copy link
Copy Markdown
Owner

BoboTiG commented Apr 29, 2026

And #519 is merged. Can you rebase your PR @mxschmitt?

@halldorfannar
Copy link
Copy Markdown
Contributor

Reporting back on my benchmarks. The overhead of acquiring and releasing the device contexts is minimal. I've measured on 60hz, 75hz, and 120hz configurations. We should therefore acquire and release memdcs/srcdc within the grab implementation. This will solve any threading issues that this original PR was running into while trying to lazily acquire them.

@mxschmitt mxschmitt force-pushed the fix/lazy-init-windows-dcs branch from 596a3f4 to f2758ae Compare April 29, 2026 17:13
Move GetWindowDC(0) + CreateCompatibleDC out of __init__ and into each
grab() call, releasing them in a try/finally before returning.

Benefits:
- MSS() construction no longer calls GetWindowDC(0), so sct.monitors
  works even when the desktop DC is unavailable (locked screen, UAC,
  RDP disconnect, GDI handle pressure)
- DCs are acquired and released on the same thread within the same call,
  eliminating the cross-thread ReleaseDC failures seen in CI

Fixes BoboTiG#509.
Comment thread src/tests/test_windows.py Outdated
@BoboTiG
Copy link
Copy Markdown
Owner

BoboTiG commented Apr 29, 2026

Could you add a line or two in docs/source/release-history/v11.0.0.md?

- Rewrite regression test to simulate GetWindowDC failure and verify
  monitors still work while grab() properly raises ScreenShotError
- Add changelog entry to v11.0.0.md
@mxschmitt mxschmitt force-pushed the fix/lazy-init-windows-dcs branch from f2758ae to 7fc867e Compare April 29, 2026 18:11
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

GetWindowDC(0) called unconditionally in __init__ prevents monitor-only usage

4 participants