Skip to content

Commit dc914e4

Browse files
committed
XCB: Verify visual, and improve error messages
The MSS object, when it's created, will now ensure that the X11 visual's format is exactly what we think it is: 32bpp, as a 24-bit BGRx or 32-bit BGRA image, in that order, with no extra scanline padding, color indexing, etc. This is true of all modern X servers in real-world situations, but it's not guaranteed. For instance, VNC-based servers can be run in depth 16 with RGB565 formats. The error messages for protocol-level errors (such as bad coordinates) are only available if the xcb-util-errors library is installed. (That's libxcb-errors.so.0, in the libxcb-errors0 package on Debian/Ubuntu.) This isn't widely installed, and is mostly useful to developers, so it's optional. The connection-level errors (like the hint to check $DISPLAY) are available everywhere.
1 parent 5a33e8c commit dc914e4

File tree

4 files changed

+228
-57
lines changed

4 files changed

+228
-57
lines changed

src/mss/linux/xcb.py

Lines changed: 123 additions & 35 deletions
Original file line numberDiff line numberDiff line change
@@ -171,36 +171,65 @@ class XError(ScreenShotError):
171171
class XProtoError(XError):
172172
"""Exception indicating server-reported errors."""
173173

174-
def __init__(self, _xcb_conn: XcbConnection, xcb_err: XcbGenericErrorStructure) -> None:
174+
def __init__(self, xcb_conn: XcbConnection, xcb_err: XcbGenericErrorStructure) -> None:
175175
if isinstance(xcb_err, _Pointer):
176176
xcb_err = xcb_err.contents
177-
# I would verify isinstance(xcb_err,
178-
# XcbGenericErrorStructure), but ruff doesn't really give me a
179-
# great way to throw an appropriate exception here.
180-
# FIXME Get the text descriptions, if possible. That's why we
181-
# got the connection handle: that can be necessary to get error
182-
# descriptions for extensions.
183-
super().__init__(
184-
"X11 Protocol Error",
185-
details={
186-
"error_code": xcb_err.error_code,
187-
"sequence": xcb_err.sequence,
188-
"resource_id": xcb_err.resource_id,
189-
"minor_code": xcb_err.minor_code,
190-
"major_code": xcb_err.major_code,
191-
"full_sequence": xcb_err.full_sequence,
192-
},
193-
)
177+
assert isinstance(xcb_err, XcbGenericErrorStructure) # noqa: S101
178+
179+
details = {
180+
"error_code": xcb_err.error_code,
181+
"sequence": xcb_err.sequence,
182+
"resource_id": xcb_err.resource_id,
183+
"minor_code": xcb_err.minor_code,
184+
"major_code": xcb_err.major_code,
185+
"full_sequence": xcb_err.full_sequence,
186+
}
187+
188+
# xcb-errors is a library to get descriptive error strings, instead of reporting the raw codes. This is not
189+
# installed by default on most systems, but is quite helpful for developers. We use it if it exists, but don't
190+
# force the matter. We can't do this when we format the error message, since the XCB connection may be gone.
191+
if xcb_errors:
192+
# We don't try to reuse the error context, since it's per-connection, and probably will only be used once.
193+
ctx = POINTER(XcbErrorsContext)()
194+
ctx_new_setup = xcb_errors.xcb_errors_context_new(xcb_conn, ctx)
195+
if ctx_new_setup == 0:
196+
try:
197+
# Some of these may return NULL, but some are guaranteed.
198+
ext_name = POINTER(c_char_p)()
199+
error_name = xcb_errors.xcb_errors_get_name_for_error(ctx, xcb_err.error_code, ext_name)
200+
details["error"] = error_name.decode("ascii", errors="replace")
201+
if ext_name:
202+
details["extension"] = ext_name.decode("ascii", errors="replace")
203+
major_name = xcb_errors.xcb_errors_get_name_for_major_code(ctx, xcb_err.major_code)
204+
details["major_name"] = major_name.decode("ascii", errors="replace")
205+
minor_name = xcb_errors.xcb_errors_get_name_for_minor_code(
206+
ctx, xcb_err.major_code, xcb_err.minor_code
207+
)
208+
if minor_name:
209+
details["minor_name"] = minor_name.decode("ascii", errors="replace")
210+
finally:
211+
xcb_errors.xcb_errors_context_free(ctx)
212+
213+
super().__init__("X11 Protocol Error", details=details)
194214

195215
def __str__(self) -> str:
196216
msg = super().__str__()
197-
err = self.details
217+
details = self.details
218+
error_desc = f"{details['error_code']} ({details['error']})" if "error" in details else details["error_code"]
219+
major_desc = (
220+
f"{details['major_code']} ({details['major_name']})" if "major_name" in details else details["major_code"]
221+
)
222+
minor_desc = (
223+
f"{details['minor_code']} ({details['minor_name']})" if "minor_name" in details else details["minor_code"]
224+
)
225+
ext_desc = f"\n Extension: {details['ext_name']}" if "ext_desc" in details else ""
198226
msg += (
199-
f"\nX Error of failed request: {err['error_code']}"
200-
f"\n Major opcode of failed request: {err['major_code']}"
201-
f"\n Minor opcode of failed request: {err['minor_code']}"
202-
f"\n Resource id in failed request: {err['resource_id']}"
203-
f"\n Serial number of failed request: {err['full_sequence']}"
227+
f"\nX Error of failed request: {error_desc}"
228+
f"\n Major opcode of failed request: {major_desc}"
229+
f"{ext_desc}"
230+
f"\n Minor opcode of failed request: {minor_desc}"
231+
f"\n Resource id in failed request: {details['resource_id']}"
232+
f"\n Serial number of failed request: {details['full_sequence']}"
204233
)
205234
return msg
206235

@@ -234,7 +263,6 @@ def check(self, xcb_conn: XcbConnection) -> None:
234263
235264
This will raise an exception if there is an error.
236265
"""
237-
self._xcb_handled_ = True
238266
err_p = libxcb.xcb_request_check(xcb_conn, self)
239267
if not err_p:
240268
return
@@ -555,11 +583,30 @@ class XcbQueryExtensionReply(Structure):
555583
# for many core X requests.
556584
XCB_NONE = XID(0)
557585
XCB_ATOM_WINDOW = XcbAtom(33)
586+
XCB_CONN_ERROR = 1
587+
XCB_CONN_CLOSED_EXT_NOTSUPPORTED = 2
588+
XCB_CONN_CLOSED_MEM_INSUFFICIENT = 3
589+
XCB_CONN_CLOSED_REQ_LEN_EXCEED = 4
590+
XCB_CONN_CLOSED_PARSE_ERR = 5
591+
XCB_CONN_CLOSED_INVALID_SCREEN = 6
592+
XCB_CONN_CLOSED_FDPASSING_FAILED = 7
558593
XCB_IMAGE_FORMAT_Z_PIXMAP = 2
559594
XCB_IMAGE_ORDER_LSB_FIRST = 0
560595
XCB_VISUAL_CLASS_TRUE_COLOR = 4
561596
XCB_VISUAL_CLASS_DIRECT_COLOR = 5
562597

598+
# I don't know of error descriptions for the XCB connection errors being accessible through a library (a la strerror),
599+
# and the ones in xcb.h's comments aren't too great, so I wrote these.
600+
XCB_CONN_ERRMSG = {
601+
XCB_CONN_ERROR: "connection lost or could not be established",
602+
XCB_CONN_CLOSED_EXT_NOTSUPPORTED: "extension not supported",
603+
XCB_CONN_CLOSED_MEM_INSUFFICIENT: "memory exhausted",
604+
XCB_CONN_CLOSED_REQ_LEN_EXCEED: "request length longer than server accepts",
605+
XCB_CONN_CLOSED_PARSE_ERR: "display is unset or invalid (check $DISPLAY)",
606+
XCB_CONN_CLOSED_INVALID_SCREEN: "server does not have a screen matching the requested display",
607+
XCB_CONN_CLOSED_FDPASSING_FAILED: "could not pass file descriptor",
608+
}
609+
563610
# randr
564611

565612

@@ -769,6 +816,19 @@ class XcbXfixesGetCursorImageReply(Structure):
769816
XCB_XFIXES_MAJOR_VERSION = 6
770817
XCB_XFIXES_MINOR_VERSION = 0
771818

819+
# xcb-errors
820+
821+
822+
class XcbErrorsContext(Structure):
823+
"""A context for using this library.
824+
825+
Create a context with xcb_errors_context_new() and destroy it with xcb_errors_context_free(). Except for
826+
xcb_errors_context_free(), all functions in this library are thread-safe and can be called from multiple threads at
827+
the same time, even on the same context.
828+
"""
829+
830+
# Opaque
831+
772832

773833
#### ctypes initialization
774834

@@ -784,7 +844,7 @@ def initialize() -> None:
784844
if _INITIALIZE_DONE:
785845
return
786846

787-
global libc, libxcb, randr, xcb_randr_id, render, xcb_render_id, xfixes, xcb_xfixes_id
847+
global libc, libxcb, randr, xcb_randr_id, render, xcb_render_id, xfixes, xcb_xfixes_id, xcb_errors
788848

789849
# We don't use the cached versions that ctypes.cdll exposes as
790850
# attributes, since other libraries may be doing their own
@@ -958,6 +1018,24 @@ def initialize() -> None:
9581018
xfixes.xcb_xfixes_get_cursor_image_cursor_image_length.argtypes = [POINTER(XcbXfixesGetCursorImageReply)]
9591019
xfixes.xcb_xfixes_get_cursor_image_cursor_image_length.restype = c_int
9601020

1021+
# xcb_errors is an optional library, mostly only useful to developers.
1022+
# We use the qualified .so name, since it's subject to change.
1023+
try:
1024+
xcb_errors = cdll.LoadLibrary("libxcb-errors.so.0")
1025+
except Exception: # noqa: BLE001
1026+
xcb_errors = None
1027+
else:
1028+
xcb_errors.xcb_errors_context_new.argtypes = [POINTER(XcbConnection), POINTER(POINTER(XcbErrorsContext))]
1029+
xcb_errors.xcb_errors_context_new.restype = c_int
1030+
xcb_errors.xcb_errors_context_free.argtypes = [POINTER(XcbErrorsContext)]
1031+
xcb_errors.xcb_errors_context_free.restype = None
1032+
xcb_errors.xcb_errors_get_name_for_major_code.argtypes = [POINTER(XcbErrorsContext), c_uint8]
1033+
xcb_errors.xcb_errors_get_name_for_major_code.restype = c_char_p
1034+
xcb_errors.xcb_errors_get_name_for_minor_code.argtypes = [POINTER(XcbErrorsContext), c_uint8, c_uint16]
1035+
xcb_errors.xcb_errors_get_name_for_minor_code.restype = c_char_p
1036+
xcb_errors.xcb_errors_get_name_for_error.argtypes = [POINTER(XcbErrorsContext), c_uint8, POINTER(c_char_p)]
1037+
xcb_errors.xcb_errors_get_name_for_error.restype = c_char_p
1038+
9611039
_INITIALIZE_DONE = True
9621040

9631041

@@ -1290,22 +1368,32 @@ def connect(display: str | bytes | None = None) -> tuple[XcbConnection, int]:
12901368

12911369
pref_screen_num = c_int()
12921370
conn_p = libxcb.xcb_connect(display, pref_screen_num)
1293-
if libxcb.xcb_connection_has_error(conn_p) != 0:
1294-
# FIXME Get the error information
1295-
msg = "Cannot connect to display"
1371+
1372+
# We still get a connection object even if the connection fails.
1373+
conn_err = libxcb.xcb_connection_has_error(conn_p)
1374+
if conn_err != 0:
1375+
msg = "Cannot connect to display: "
1376+
conn_errmsg = XCB_CONN_ERRMSG.get(conn_err)
1377+
if conn_errmsg:
1378+
msg += conn_errmsg
1379+
else:
1380+
msg += f"error code {conn_err}"
12961381
raise XError(msg)
12971382

12981383
return conn_p.contents, pref_screen_num.value
12991384

13001385

13011386
def disconnect(conn: XcbConnection) -> None:
1302-
error_status = libxcb.xcb_connection_has_error(conn)
1303-
# XCB won't free its connection structures until we disconnect,
1304-
# even in the event of an error.
1387+
conn_err = libxcb.xcb_connection_has_error(conn)
1388+
# XCB won't free its connection structures until we disconnect, even in the event of an error.
13051389
libxcb.xcb_disconnect(conn)
1306-
if error_status != 0:
1307-
# FIXME Get the error information
1308-
msg = "Connection closed due to errors"
1390+
if conn_err != 0:
1391+
msg = "Connection to X server closed: "
1392+
conn_errmsg = XCB_CONN_ERRMSG.get(conn_err)
1393+
if conn_errmsg:
1394+
msg += conn_errmsg
1395+
else:
1396+
msg += f"error code {conn_err}"
13091397
raise XError(msg)
13101398

13111399

src/mss/linux/xgetimage.py

Lines changed: 84 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -11,13 +11,17 @@
1111
from . import xcb
1212
from .xcb import (
1313
XCB_IMAGE_FORMAT_Z_PIXMAP,
14+
XCB_IMAGE_ORDER_LSB_FIRST,
1415
XCB_RANDR_MAJOR_VERSION,
1516
XCB_RANDR_MINOR_VERSION,
17+
XCB_VISUAL_CLASS_DIRECT_COLOR,
18+
XCB_VISUAL_CLASS_TRUE_COLOR,
1619
XCB_XFIXES_MAJOR_VERSION,
1720
XCB_XFIXES_MINOR_VERSION,
1821
connect,
1922
disconnect,
2023
initialize,
24+
xcb_depth_visuals,
2125
xcb_get_geometry,
2226
xcb_get_image,
2327
xcb_get_image_data,
@@ -27,14 +31,19 @@
2731
xcb_randr_get_screen_resources_current,
2832
xcb_randr_get_screen_resources_current_crtcs,
2933
xcb_randr_query_version,
34+
xcb_screen_allowed_depths,
35+
xcb_setup_pixmap_formats,
3036
xcb_setup_roots,
3137
xcb_xfixes_get_cursor_image,
3238
xcb_xfixes_get_cursor_image_cursor_image,
3339
xcb_xfixes_query_version,
3440
)
3541

3642
SUPPORTED_DEPTHS = {24, 32}
37-
SUPPORTED_BYTES_PER_PIXEL = 32
43+
SUPPORTED_BITS_PER_PIXEL = 32
44+
SUPPORTED_RED_MASK = 0xFF0000
45+
SUPPORTED_GREEN_MASK = 0x00FF00
46+
SUPPORTED_BLUE_MASK = 0x0000FF
3847
ALL_PLANES = 0xFFFFFFFF # XCB doesn't define AllPlanes
3948

4049

@@ -47,7 +56,7 @@ class MSS(MSSBase):
4756
* XFixes: Including the cursor.
4857
"""
4958

50-
def __init__(self, /, **kwargs: Any) -> None:
59+
def __init__(self, /, **kwargs: Any) -> None: # noqa: PLR0912
5160
super().__init__(**kwargs)
5261

5362
display = kwargs.get("display", b"")
@@ -72,6 +81,71 @@ def __init__(self, /, **kwargs: Any) -> None:
7281
# We don't probe the XFixes presence or version until we need it.
7382
self._xfixes_ready = None
7483

84+
# Probe the visuals (and related information), and make sure that our drawable is in an acceptable format.
85+
# These iterations and tests don't involve any traffic with the server; it's all stuff that was included in the
86+
# connection setup. Effectively all modern setups will be acceptable, but we verify to be sure.
87+
88+
# Currently, we assume that the drawable we're capturing is the root; when we add single-window capture, we'll
89+
# have to ask the server for its depth and visual.
90+
assert self.root == self.drawable # noqa: S101
91+
self.drawable_depth = pref_screen.root_depth
92+
self.drawable_visual_id = pref_screen.root_visual.value
93+
# Server image byte order
94+
if xcb_setup.image_byte_order != XCB_IMAGE_ORDER_LSB_FIRST:
95+
msg = "Only X11 servers using LSB-First images are supported."
96+
raise ScreenShotError(msg)
97+
# Depth
98+
if self.drawable_depth not in SUPPORTED_DEPTHS:
99+
msg = f"Only screens of color depth 24 or 32 are supported, not {self.drawable_depth}"
100+
raise ScreenShotError(msg)
101+
# Format (i.e., bpp, padding)
102+
for format_ in xcb_setup_pixmap_formats(xcb_setup):
103+
if format_.depth == self.drawable_depth:
104+
break
105+
else:
106+
msg = "Internal error: drawable's depth not found in screen's supported formats"
107+
raise ScreenShotError(msg)
108+
drawable_format = format_
109+
if drawable_format.bits_per_pixel != SUPPORTED_BITS_PER_PIXEL:
110+
msg = (
111+
f"Only screens at 32 bpp (regardless of color depth) are supported; "
112+
f"got {drawable_format.bits_per_pixel} bpp"
113+
)
114+
raise ScreenShotError(msg)
115+
if drawable_format.scanline_pad != SUPPORTED_BITS_PER_PIXEL:
116+
# To clarify the padding: the scanline_pad is the multiple that the scanline gets padded to. If there is
117+
# no padding, then it will be the same as one pixel's size.
118+
msg = "Screens with scanline padding are not supported"
119+
raise ScreenShotError(msg)
120+
# Visual, the interpretation of pixels (like indexed, grayscale, etc). (Visuals are arranged by depth, so we
121+
# iterate over the depths first.)
122+
for xcb_depth in xcb_screen_allowed_depths(pref_screen):
123+
if xcb_depth.depth == self.drawable_depth:
124+
break
125+
else:
126+
msg = "Internal error: drawable's depth not found in screen's supported depths"
127+
raise ScreenShotError(msg)
128+
for visual_info in xcb_depth_visuals(xcb_depth):
129+
if visual_info.visual_id.value == self.drawable_visual_id:
130+
break
131+
else:
132+
msg = "Internal error: drawable's visual not found in screen's supported visuals"
133+
raise ScreenShotError(msg)
134+
if visual_info.class_ not in {XCB_VISUAL_CLASS_TRUE_COLOR, XCB_VISUAL_CLASS_DIRECT_COLOR}:
135+
msg = "Only TrueColor and DirectColor visuals are supported"
136+
raise ScreenShotError(msg)
137+
if (
138+
visual_info.red_mask != SUPPORTED_RED_MASK
139+
or visual_info.green_mask != SUPPORTED_GREEN_MASK
140+
or visual_info.blue_mask != SUPPORTED_BLUE_MASK
141+
):
142+
# There are two ways to phrase this layout: BGRx accounts for the byte order, while xRGB implies the native
143+
# word order. Since we return the data as a byte array, we use the former. By the time we get to this
144+
# point, we've already checked the endianness and depth, so this is pretty much never going to happen
145+
# anyway.
146+
msg = "Only visuals with BGRx ordering are supported"
147+
raise ScreenShotError(msg)
148+
75149
def close(self) -> None:
76150
if self.conn is not None:
77151
disconnect(self.conn)
@@ -159,18 +233,13 @@ def _grab_impl(self, monitor: Monitor, /) -> ScreenShot:
159233
# we clear the image structure.
160234
img_data = bytearray(img_data_arr)
161235

162-
# FIXME In xcb.main(), there's a more complete implementation
163-
# of checking the bit depth. This includes checking the
164-
# visual type, byte order, and also determines whether the
165-
# alpha channel is real or just padding.
166-
#
167-
# In XCB, we don't get the bits per pixel with the image; it's
168-
# actually part of the visual data. (Xlib puts the necessary
169-
# visual data in the image structure automatically.) So for
170-
# now, we just check the depth, until I get around to checking
171-
# the full visual.
172-
if img_reply.depth not in SUPPORTED_DEPTHS:
173-
msg = f"[GetImage] depth value not implemented: {img_reply.depth}."
236+
if img_reply.depth != self.drawable_depth or img_reply.visual.value != self.drawable_visual_id:
237+
# This should never happen; a window can't change its visual.
238+
msg = (
239+
"Server returned an image with a depth or visual different than it initially reported: "
240+
f"expected {self.drawable_depth},{hex(self.drawable_visual_id)}, "
241+
f"got {img_reply.depth},{hex(img_reply.visual.value)}"
242+
)
174243
raise ScreenShotError(msg)
175244

176245
return self.cls_image(img_data, monitor)
@@ -184,7 +253,7 @@ def _cursor_impl_check_xfixes(self) -> bool:
184253
# We can work with 2.0 and later, but not sure about the
185254
# actual minimum version we can use. That's ok; everything
186255
# these days is much more modern.
187-
return not (reply.major_version, reply.minor_version) < (2, 0)
256+
return (reply.major_version, reply.minor_version) >= (2, 0)
188257

189258
def _cursor_impl(self) -> ScreenShot:
190259
"""Retrieve all cursor data. Pixels have to be RGBx."""

src/mss/linux/xlib.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -271,7 +271,7 @@ def __str__(self) -> str:
271271
if self.details["request_code"] >= X_FIRST_EXTENSION_OPCODE:
272272
msg += f"\n Minor opcode of failed request: {self.details['minor_code']}"
273273
msg += (
274-
f"\n Resource id in failed request: {self.details['resourceid']}"
274+
f"\n Resource id in failed request: {self.details['resourceid'].value}"
275275
f"\n Serial number of failed request: {self.details['serial']}"
276276
)
277277
return msg

0 commit comments

Comments
 (0)