BoboTiG / python-mss

An ultra fast cross-platform multiple screenshots module in pure Python using ctypes.
https://pypi.org/project/mss/
MIT License
1.04k stars 94 forks source link

Unjustified Screenshot error after closing a Tkinter Toplevel (Linux) #220

Closed kelltom closed 1 year ago

kelltom commented 1 year ago

General information:

For GNU/Linux users:

Description of the warning/error

--- The issue (and solution) is described in detail here ---

In summary, the following error occurs when trying to instantiate an mss object after opening/closing a Tkinter Toplevel window. This error does not occur on Windows.

Full message

  File "/home/my_project/src/utilities/geometry.py", line 64, in screenshot
    with mss.mss() as sct:
  File "/home/my_project/env/lib/python3.10/site-packages/mss/factory.py", line 34, in mss
    return linux.MSS(**kwargs)
  File "/home/my_project/env/lib/python3.10/site-packages/mss/linux.py", line 297, in __init__
    self.root = self.xlib.XDefaultRootWindow(self._get_display(display))
  File "/home/my_project/env/lib/python3.10/site-packages/mss/linux.py", line 184, in validate
    raise ScreenShotError(f"{func.__name__}() failed", details=details)
mss.exception.ScreenShotError: XDefaultRootWindow() failed

Screenshot error: XDefaultRootWindow() failed, {'retval': <mss.linux.LP_XWindowAttributes object at 0x7f31d8d38ac0>, 
    'args': (<mss.linux.LP_Display object at 0x7f31d8d38740>,)}

Other details

The following code can be used to reproduce the error. Simply run this program and use the GUI button to open a new window that allows you to take a screenshot - then close the pop-up window and attempt to repeat the process.

import tkinter as tk

import mss
import mss.tools

def take_screenshot():
   with mss.mss() as sct:
       screen_part = {"top": 370, "left": 1090, "width": 80, "height": 390}
       sct_img = sct.grab(screen_part)
       mss.tools.to_png(sct_img.rgb, sct_img.size, output="./output.png")

def create_top_level_win():
   top_level_win = tk.Toplevel(root)

   take_screenshot_btn = tk.Button(top_level_win, text="Take screenshot", command=take_screenshot)
   take_screenshot_btn.pack()

root = tk.Tk()

btn = tk.Button(root, text="Open TopLevel", command=create_top_level_win)
btn.pack()

root.mainloop()

A temporary workaround is to reuse a single mss object throughout the application, like so:

sct = mss.mss()

def take_screenshot():
    global sct
    screen_part = {"top": 370, "left": 1090, "width": 80, "height": 390}
    sct_img = sct.grab(screen_part)
    mss.tools.to_png(sct_img.rgb, sct_img.size, output="./output.png")

On StackOverflow, one answer suggests that the bug is due to "the current implementation of MSS changing the current X11 error handler and leaving it afterwards, which causes a conflict with Tcl/Tk (the backend of Python Tkinter), see here for details"

kelltom commented 1 year ago

I should also note that it's not as simple as the opening/closing of a Toplevel causing this issue. In a project I'm working on, this issue only occurs the 2nd time I open/close a Toplevel window (see my video linked in the StackOverflow post). The project is quite large so I'll only make effort to create a reproducible example out of it if the fix isn't obvious with the information already provided.

BoboTiG commented 1 year ago

I run the test script on Python 3.11 with MSS 7.0.1, and so far no luck reproducing the problem. Even with the video, it's not really clear how to reproduce 🤔

BoboTiG commented 1 year ago

And sorry for the late answer. I'll try to fix it ASAP so that nobody needs to use hacky code around MSS.

BoboTiG commented 1 year ago

To try making some progress, in you code, when the screenshot fails, can you add a call to sct.get_error_details() and copy/paste the result here?

Something like:

# I see `sct` is global in your project
try:
    # MSS stuff
except mss.ScreenShotError:
    global sct
    details = sct.get_error_details()

    import pprint
    pprint.pprint(details)
BoboTiG commented 1 year ago

Better, I pushed some changes on #224. Would you be able to simply try that version, and still print the details, but now as follow:

try:
    # MSS stuff
except mss.ScreenShotError as exc:
    print(exc.details)

🙏🏻 ?

kelltom commented 1 year ago

Hey! Actually, if it makes a difference, my code base is locked to Python 3.10 for the time being. I haven't had a chance to look at it, but do you think your fix would apply for it?

BoboTiG commented 1 year ago

Yes, the fix would be for all supported Python versions :)

kelltom commented 1 year ago

Cool, I'll try it out as soon as I get a chance, thanks!

BoboTiG commented 1 year ago

If #224 may not fix the issue, it will likely fix another issue regarding that any X11 error would not be cleared, and so subsequent legitimate calls to any X11 function would fail. I refactored that part, and I think it will help in your case, and your tests will be critical :)

You should just use the version of MSS from the master branch because I merged the PR.

kelltom commented 1 year ago

I've installed MSS (8.0.0) directly from the master branch and used the code I provided as a reproducible sample and the problem appears to persist.

However, more interestingly, I've also installed it into my large project and reverted my workaround to the code that existed at the time I created my Stack Overflow post, and while I still see the error message appear when I expect it to, my program can recover from it. To better explain, I see the error message when I press the Play button in my program after fiddling with some Toplevel views, but if I just press Play a second time it works perfectly fine. In comparison, when using MSS 7.0.1, my program is unable to continue as soon as I see that error message. It doesn't crash the program, but it is unable to do anything meaningful beyond that point until I restart it.

So, looks like progress, but not quite 100% solved :)

BoboTiG commented 1 year ago

We are moving in the right direction then, thanks for the testing :)

When the error happens, could you print, and copy-paste here, the exception details? And all that is printed in the output, to have a full overview.

BoboTiG commented 1 year ago

I think I've an idea of the root cause, but will need time to investigate, and create a POC. So, your help about giving the details, will be useful 👍🏻

kelltom commented 1 year ago

Will do

BoboTiG commented 1 year ago

I just pushed more breaking changes to the master branch. Be sure to use the latest commit for your tests :)

kelltom commented 1 year ago

I receive two slightly different error details depending on where I test the new changes.

In my actual project:

{'error': '138', 'error_code': 138, 'minor_code': 0, 'request_code': 54, 'serial': 50334842, 'type': 0}

In the reproducible sample code:

import tkinter as tk

import mss
import mss.tools

def take_screenshot():
   try:
      with mss.mss() as sct:
         screen_part = {"top": 370, "left": 1090, "width": 80, "height": 390}
         sct_img = sct.grab(screen_part)
         mss.tools.to_png(sct_img.rgb, sct_img.size, output="./output.png")
   except mss.ScreenShotError as exc:
      print(exc.details)

def create_top_level_win():
   top_level_win = tk.Toplevel(root)

   take_screenshot_btn = tk.Button(top_level_win, text="Take screenshot", command=take_screenshot)
   take_screenshot_btn.pack()

root = tk.Tk()

btn = tk.Button(root, text="Open TopLevel", command=create_top_level_win)
btn.pack()

root.mainloop()

Error:

{'error': '90', 'error_code': 90, 'minor_code': 0, 'request_code': 2, 'serial': 6291494, 'type': 0}
BoboTiG commented 1 year ago

Do you have the same behaviour as you commented:

However, more interestingly, I've also installed it into my large project and reverted my workaround to the code that existed at the time I created my Stack Overflow post, and while I still see the error message appear when I expect it to, my program can recover from it. To better explain, I see the error message when I press the Play button in my program after fiddling with some Toplevel views, but if I just press Play a second time it works perfectly fine. In comparison, when using MSS 7.0.1, my program is unable to continue as soon as I see that error message. It doesn't crash the program, but it is unable to do anything meaningful beyond that point until I restart it.

?

BoboTiG commented 1 year ago

Those specific error don't help me, but at least we have something. Well, I'll need to be able to reproduce first.

BoboTiG commented 1 year ago

Can you install the xtrace tool, and run the simple reproductible code using:

xtrace python ...

Just prepend xtrace to the command line you used in your previous testing. It will output a lot of stuff, save it to a log file, and attach it here please :)

It seems more a Xserver configuration issue than a MSS-specific error, but I may be wrong.

BoboTiG commented 1 year ago

Whoops, I just see I didn't read properly the reproduction steps 😓

No need for more testing on your side, I've an idea of the issue (but no solution so far).

BoboTiG commented 1 year ago

I've reproduced the issue, and even have a fix!

But I would like to also add a test case to prevent regressions. I tried to automated "clicks" on buttons using the invoke() function, it works but it doesn't reproduce the problem. Would you be able to give a hand here? That's what I've wrote so far:

import tkinter

import mss

def take_screenshot():
   with mss.mss() as sct:
       print(sct.shot())

def create_top_level_win():
    top_level_win = tkinter.Toplevel(root)
    top_level_win.protocol("WM_DELETE_WINDOW", lambda: print("closing"))
    take_screenshot_btn = tkinter.Button(top_level_win, text="Take screenshot", command=take_screenshot)
    take_screenshot_btn.pack()
    take_screenshot_btn.invoke()
    top_level_win.destroy()  # <-- culprit

root = tkinter.Tk()
btn = tkinter.Button(root, text="Open TopLevel", command=create_top_level_win)
btn.pack()

# First screenshot: it works
btn.invoke()

# Second screenshot: it should fail (but it currently works)
btn.invoke()

# root.mainloop()
BoboTiG commented 1 year ago

The issue is that when doing manual clicks, top_level_win.protocol("WM_DELETE_WINDOW", lambda: print("closing")) will ensure to print closing. So it triggers some X server code, I guess.

But top_level_win.destroy() doesn't seem to trigger the same event as doing it manually. If we can emulate the same event, I think we will be good.

BoboTiG commented 1 year ago

OK, I found the problem. Pull request incoming, sorry for the noise 😅

BoboTiG commented 1 year ago

@kelltom you are good for a final test using the master branch :)

kelltom commented 1 year ago

Looks like it's resolved! Thanks for checking this out!

relent95 commented 1 year ago

IMHO, the current fix has a potential of another side effect, because the previous X11 error handler is not restored. The previous error handler needs to be backed up in the MSS.__init__(). (The XSetErrorHandler() returns the previous error handler.) And the backed up handler needs to be restored in the MSS.close().

BoboTiG commented 1 year ago

@relent95 can you give a try, and open a PR?

relent95 commented 1 year ago

I did. I haven't write many PRs. Correct me if I made a mistake.

BoboTiG commented 1 year ago

That's perfect, thanks @relent95 💪🏻