Skip to content

Commit 7364408

Browse files
authored
Track down the closed memfd.
Previously, I had been trying to close the memfd in _shutdown_shm, and ignoring EBADF. It turns out that XCB will close the memfd when you send it to the X server. I think this was one potential cause of the issues I saw in test_thread_safety: the two threads would be reallocated each others' fds, leading to thread A closing an fd that thread B was using, thinking that it was thread A's memfd. Fix so that the memfd is only explicitly closed in an error situation.
1 parent d0bacdb commit 7364408

File tree

5 files changed

+122
-68
lines changed

5 files changed

+122
-68
lines changed

src/mss/linux/xcb.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
from __future__ import annotations
22

3-
from ctypes import Structure, _Pointer, c_int, c_uint8, c_uint16, c_uint32
3+
from ctypes import _Pointer, c_int
44

55
from . import xcbgen
66

src/mss/linux/xcbhelpers.py

Lines changed: 3 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -230,10 +230,9 @@ def __str__(self) -> str:
230230
ext_desc = f"\n Extension: {details['extension']}" if "extension" in details else ""
231231
msg += (
232232
f"\nX Error of failed request: {error_desc}"
233-
f"\n Major opcode of failed request: {major_desc}"
234-
f"{ext_desc}"
235-
f"\n Minor opcode of failed request: {minor_desc}"
236-
f"\n Resource id in failed request: {details['resource_id']}"
233+
f"\n Major opcode of failed request: {major_desc}{ext_desc}"
234+
+ (f"\n Minor opcode of failed request: {minor_desc}" if details["minor_code"] != 0 else "")
235+
+ f"\n Resource id in failed request: {details['resource_id']}"
237236
f"\n Serial number of failed request: {details['full_sequence']}"
238237
)
239238
return msg

src/mss/linux/xshmgetimage.py

Lines changed: 80 additions & 59 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,9 @@
11
from __future__ import annotations
22

3-
import errno
3+
import enum
44
import os
55
from mmap import PROT_READ, mmap # type: ignore[attr-defined]
6-
from typing import TYPE_CHECKING, Any, Literal
6+
from typing import TYPE_CHECKING, Any
77

88
from mss.exception import ScreenShotError
99
from mss.linux import xcb
@@ -16,6 +16,12 @@
1616
from mss.screenshot import ScreenShot
1717

1818

19+
class ShmStatus(enum.Enum):
20+
UNKNOWN = enum.auto()
21+
AVAILABLE = enum.auto()
22+
UNAVAILABLE = enum.auto()
23+
24+
1925
class MSS(MSSXCBBase):
2026
"""Multiple ScreenShots implementation for GNU/Linux.
2127
@@ -31,65 +37,78 @@ def __init__(self, /, **kwargs: Any) -> None:
3137
self._buf: mmap | None = None
3238
self._shmseg: xcb.ShmSeg | None = None
3339

34-
# _shm_works is True if at least one screenshot has been taken, False if it's known to fail, and None until
35-
# then.
36-
self._shm_works: bool | None = self._setup_shm()
40+
self.shm_status: ShmStatus = self._setup_shm()
41+
self.shm_failed_reason: str | None = None
3742

3843
def _shm_report_issue(self, msg: str, *args: Any) -> None:
3944
"""Debugging hook for troubleshooting MIT-SHM issues.
4045
4146
This will be called whenever MIT-SHM is disabled. The optional
4247
arguments are not well-defined; exceptions are common.
4348
"""
44-
print(msg, args)
49+
full_msg = msg
50+
if args:
51+
full_msg += " | " + ", ".join(str(arg) for arg in args)
52+
self.shm_failed_reason = full_msg
4553

46-
def _setup_shm(self) -> Literal[False] | None:
54+
def _setup_shm(self) -> ShmStatus: # noqa: PLR0911
4755
assert self.conn is not None # noqa: S101
4856

49-
shm_ext_data = xcb.get_extension_data(self.conn, LIB.shm_id)
50-
if not shm_ext_data.present:
51-
self._shm_report_issue("MIT-SHM extension not present")
52-
return False
53-
54-
# We use the FD-based version of ShmGetImage, so we require the extension to be at least 1.3.
55-
shm_version_data = xcb.shm_query_version(self.conn)
56-
shm_version = (shm_version_data.major_version, shm_version_data.minor_version)
57-
if shm_version < (1, 2):
58-
self._shm_report_issue("MIT-SHM version too old", shm_version)
59-
return False
60-
61-
# We allocate something large enough for the root, so we don't have to reallocate each time the window is
62-
# resized.
63-
# TODO(jholveck): Check in _grab_impl that we're not going to exceed this size. That can happen if the
64-
# root is resized.
65-
size = self.pref_screen.width_in_pixels * self.pref_screen.height_in_pixels * 4
66-
6757
try:
68-
self._memfd = os.memfd_create("mss-shm-buf", flags=os.MFD_CLOEXEC) # type: ignore[attr-defined]
69-
except OSError as e:
70-
self._shm_report_issue("memfd_create failed", e)
71-
self._shutdown_shm()
72-
return False
73-
os.ftruncate(self._memfd, size)
58+
shm_ext_data = xcb.get_extension_data(self.conn, LIB.shm_id)
59+
if not shm_ext_data.present:
60+
self._shm_report_issue("MIT-SHM extension not present")
61+
return ShmStatus.UNAVAILABLE
62+
63+
# We use the FD-based version of ShmGetImage, so we require the extension to be at least 1.2.
64+
shm_version_data = xcb.shm_query_version(self.conn)
65+
shm_version = (shm_version_data.major_version, shm_version_data.minor_version)
66+
if shm_version < (1, 2):
67+
self._shm_report_issue("MIT-SHM version too old", shm_version)
68+
return ShmStatus.UNAVAILABLE
69+
70+
# We allocate something large enough for the root, so we don't have to reallocate each time the window is
71+
# resized.
72+
self._bufsize = self.pref_screen.width_in_pixels * self.pref_screen.height_in_pixels * 4
73+
74+
if not hasattr(os, "memfd_create"):
75+
self._shm_report_issue("os.memfd_create not available")
76+
return ShmStatus.UNAVAILABLE
77+
try:
78+
self._memfd = os.memfd_create("mss-shm-buf", flags=os.MFD_CLOEXEC) # type: ignore[attr-defined]
79+
except OSError as e:
80+
self._shm_report_issue("memfd_create failed", e)
81+
self._shutdown_shm()
82+
return ShmStatus.UNAVAILABLE
83+
os.ftruncate(self._memfd, self._bufsize)
7484

75-
try:
76-
self._buf = mmap(self._memfd, size, prot=PROT_READ) # type: ignore[call-arg]
77-
except OSError as e:
78-
self._shm_report_issue("mmap failed", e)
79-
self._shutdown_shm()
80-
return False
85+
try:
86+
self._buf = mmap(self._memfd, self._bufsize, prot=PROT_READ) # type: ignore[call-arg]
87+
except OSError as e:
88+
self._shm_report_issue("mmap failed", e)
89+
self._shutdown_shm()
90+
return ShmStatus.UNAVAILABLE
8191

82-
self._shmseg = xcb.ShmSeg(xcb.generate_id(self.conn).value)
83-
try:
84-
# This will normally be what raises an exception if you're on a remote connection. I previously thought
85-
# the server deferred that until the GetImage call, but I had not been properly checking the status here.
86-
xcb.shm_attach_fd(self.conn, self._shmseg, self._memfd, read_only=False)
87-
except xcb.XError as e:
88-
self._shm_report_issue("Cannot attach MIT-SHM segment", e)
92+
self._shmseg = xcb.ShmSeg(xcb.generate_id(self.conn).value)
93+
try:
94+
# This will normally be what raises an exception if you're on a remote connection. (I previously
95+
# thought the server deferred that until the GetImage call, but I had not been properly checking the
96+
# status.)
97+
# This will close _memfd, on success or on failure.
98+
try:
99+
xcb.shm_attach_fd(self.conn, self._shmseg, self._memfd, read_only=False)
100+
finally:
101+
self._memfd = None
102+
except xcb.XError as e:
103+
self._shm_report_issue("Cannot attach MIT-SHM segment", e)
104+
self._shutdown_shm()
105+
return ShmStatus.UNAVAILABLE
106+
107+
except Exception:
89108
self._shutdown_shm()
90-
return False
109+
raise
91110

92-
return None
111+
return ShmStatus.UNKNOWN
93112

94113
def close(self) -> None:
95114
self._shutdown_shm()
@@ -103,15 +122,7 @@ def _shutdown_shm(self) -> None:
103122
self._buf.close()
104123
self._buf = None
105124
if self._memfd is not None:
106-
# TODO(jholveck): For some reason, at this point, self._memfd is no longer valid. If I try to close it,
107-
# I get EBADF, even if I try to close it before closing the mmap. The theories I have about this involve
108-
# the mmap object taking control, but it doesn't make sense that I could still use shm_attach_fd in that
109-
# case. I need to investigate before releasing.
110-
try:
111-
os.close(self._memfd)
112-
except OSError as e:
113-
if e.errno != errno.EBADF:
114-
raise
125+
os.close(self._memfd)
115126
self._memfd = None
116127

117128
def _grab_impl_xshmgetimage(self, monitor: Monitor) -> ScreenShot:
@@ -121,6 +132,16 @@ def _grab_impl_xshmgetimage(self, monitor: Monitor) -> ScreenShot:
121132
assert self._buf is not None # noqa: S101
122133
assert self._shmseg is not None # noqa: S101
123134

135+
required_size = monitor["width"] * monitor["height"] * 4
136+
if required_size > self._bufsize:
137+
# This is temporary. The permanent fix will depend on how
138+
# issue https://github.com/BoboTiG/python-mss/issues/432 is resolved.
139+
msg = (
140+
"Requested capture size exceeds the allocated buffer. If you have resized the screen, "
141+
"please recreate your MSS object."
142+
)
143+
raise ScreenShotError(msg)
144+
124145
img_reply = xcb.shm_get_image(
125146
self.conn,
126147
self.drawable.value,
@@ -154,19 +175,19 @@ def _grab_impl_xshmgetimage(self, monitor: Monitor) -> ScreenShot:
154175

155176
def _grab_impl(self, monitor: Monitor) -> ScreenShot:
156177
"""Retrieve all pixels from a monitor. Pixels have to be RGBX."""
157-
if self._shm_works == False: # noqa: E712
178+
if self.shm_status == ShmStatus.UNAVAILABLE:
158179
return super()._grab_impl_xgetimage(monitor)
159180

160181
try:
161182
rv = self._grab_impl_xshmgetimage(monitor)
162183
except XProtoError as e:
163-
if self._shm_works is not None:
184+
if self.shm_status != ShmStatus.UNKNOWN:
164185
raise
165186
self._shm_report_issue("MIT-SHM GetImage failed", e)
166-
self._shm_works = False
187+
self.shm_status = ShmStatus.UNAVAILABLE
167188
self._shutdown_shm()
168189
rv = super()._grab_impl_xgetimage(monitor)
169190
else:
170-
self._shm_works = True
191+
self.shm_status = ShmStatus.AVAILABLE
171192

172193
return rv

src/tests/test_gnu_linux.py

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -310,3 +310,41 @@ def test_with_cursor_failure(display: str) -> None:
310310
pytest.raises(ScreenShotError),
311311
):
312312
sct.grab(sct.monitors[1])
313+
314+
315+
def test_shm_available() -> None:
316+
"""Verify that the xshmgetimage backend doesn't always fallback.
317+
318+
Since this backend does an automatic fallback for certain types of
319+
anticipated issues, that could cause some failures to be masked.
320+
Ensure this isn't happening.
321+
"""
322+
with (
323+
pyvirtualdisplay.Display(size=(WIDTH, HEIGHT), color_depth=DEPTH) as vdisplay,
324+
mss.mss(display=vdisplay.new_display_var, backend="xshmgetimage") as sct,
325+
):
326+
assert isinstance(sct, mss.linux.xshmgetimage.MSS) # For Mypy
327+
# The status currently isn't established as final until a grab succeeds.
328+
sct.grab(sct.monitors[0])
329+
assert sct.shm_status == mss.linux.xshmgetimage.ShmStatus.AVAILABLE
330+
331+
332+
def test_shm_fallback() -> None:
333+
"""Verify that the xshmgetimage backend falls back if MIT-SHM fails.
334+
335+
The most common case when a fallback is needed is with a TCP
336+
connection, such as the one used with ssh relaying. By using
337+
DISPLAY=localhost:99 instead of DISPLAY=:99, we connect over TCP
338+
instead of a local-domain socket. This is sufficient to prevent
339+
MIT-SHM from completing its setup: the extension is available, but
340+
won't be able to attach a shared memory segment.
341+
"""
342+
with (
343+
pyvirtualdisplay.Display(size=(WIDTH, HEIGHT), color_depth=DEPTH, extra_args=["-listen", "tcp"]) as vdisplay,
344+
mss.mss(display=f"localhost{vdisplay.new_display_var}", backend="xshmgetimage") as sct,
345+
):
346+
assert isinstance(sct, mss.linux.xshmgetimage.MSS) # For Mypy
347+
# Ensure that the grab call completes without exception.
348+
sct.grab(sct.monitors[0])
349+
# Ensure that it really did have to fall back; otherwise, we'd need to change how we test this case.
350+
assert sct.shm_status == mss.linux.xshmgetimage.ShmStatus.UNAVAILABLE

src/tests/test_implementation.py

Lines changed: 0 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -245,10 +245,6 @@ def test_grab_with_tuple_percents(mss_impl: Callable[..., MSSBase]) -> None:
245245
def test_thread_safety(backend: str) -> None:
246246
"""Regression test for issue #169."""
247247

248-
# This currently breaks on xshmgetimage. Resource exhaustion? Something leaking?
249-
if backend == "xshmgetimage":
250-
pytest.xfail()
251-
252248
def record(check: dict) -> None:
253249
"""Record for one second."""
254250
start_time = time.time()

0 commit comments

Comments
 (0)