A Weird Imagination

Troubleshooting python-xcffib

The problem#

The monitor-lock.py script in my previous blog post uses python-xlib, which currently mainly relies on manually porting Xlib functions to Python. This is why it is missing the barrier-related functions I needed in that post. There is work on automating this process, but it appears to be abandoned. I started trying to pick up where they had left off before finding the python-xcffib project which provides auto-generated bindings for libxcb and therefore gives full support for interacting with X at a low level from Python.

python-xcffib (named after the cffi library it uses for binding to the C XCB library) gives a slightly lower-level API than python-xlib, but they are both fairly thin wrappers over the X protocol, so the differences are minor. It was fairly straightforward to port my script from the previous post to use python-xcffib, available as monitor-lock-xcb.py.

Unfortunately, I ran into a bug in python-xcffib:

Traceback (most recent call last):
...
  File "./monitor-lock-xcb.py", line 38, in main
    devices = conn.xinput.XIQueryDevice(xcffib.xinput.Device.AllMaster).reply().infos
...
  File "/usr/lib/python3/dist-packages/xcffib/__init__.py", line 139, in _resize
    assert self.size + increment <= self.known_max
AssertionError

The solution#

I've submitted the fix upstream, so most likely you will not encounter this error. Updating to the latest version (after v0.8.1) should be sufficient to fix the problem.

The fix I applied was to modify the module's __init__.py (the location, which may be different on your machine, is in the stack trace). Specifically, on line 108 in the function Unpacker.unpack(), in the call to struct.calcsize(), change fmt to "=" + fmt.

The details#

The bug#

The original code#

The Unpacker class is a helper for unpacking binary data formats used in the X protocol. It keeps track of the position in a stream of bytes and helps with converting that stream into a collection of values.

The Unpacker.unpack() function reads the next one or more values from the stream and updates the offset into the stream so the next operation works with the bytes after those values. It uses the Python struct module to do the actual work of dealing with the binary format. The values to be read are specified using a struct format string.

Here's the original source code of the function:

def unpack(self, fmt, increment=True):
    size = struct.calcsize(fmt)
    if size > self.size - self.offset:
        self._resize(size)
    ret = struct.unpack_from("=" + fmt, self.buf, self.offset)

    if increment:
        self.offset += size
    return ret

Format string mismatch#

The issue is that the struct.unpack_from() and struct.calcsize() calls are given different format strings, so they don't act on the same format. The = prefix tells the struct module to use standardized sizes and alignments when interpreting the format string as opposed to the default (which can also be explicitly requested with @) of using the native sizes and alignments. Which means the difference between fmt and "=" + fmt is only important if the native settings disagree with the standardized ones in a relevant way. For instance, on my machine:

>>> struct.calcsize('HI')
8
>>> struct.calcsize('=HI')
6

Looking at the chart of format strings, HI means an unsigned short (2 bytes) followed by an unsigned int (4 bytes). Obviously, 2+4=6, so it seems reasonable to expect the second answer. But it comes out to 8 instead due to the 4-byte value being 4-byte aligned. That is, 2 bytes of padding are added after the 2-byte value so the 4-byte value ends up at an offset which is a multiple of 4.

Presumably, the native alignment is different on the original developer's machine. Or format strings for which it matters simply don't appear very often, so they never ran into this issue.

Troubleshooting Python#

One of the reasons I like using libraries written in Python is that it's very easy to modify them as there's no explicit compilation step required: it's as simple as finding the installed copy of the library and editing it in-place. So I started my troubleshooting simply by editing the code in the stack trace to add print statements to get more information.

Finding the bug#

As I had ported the code over from a version that used python-xlib, I had two copies of the code that should be reading the exact same values. I added print statements to both trying to see where they differed. I was also looking at xinput.h and /usr/share/xcb/xinput.xml (in the xcb-proto package) trying to determine where python-xcffib was not following the protocol.

Printing out a value that differed#

In the python-xlib version, I printed out the correct result of

xinput.query_device(window, xinput.AllMasterDevices).devices

and in the xcffib source of xinput.py, I added print statements until I found the first difference. The first one that displayed a difference was in the class DeviceClass under the if self.type == DeviceClassType.Valuator branch:

print("Valuator (size=%d): number=%d, label=%d, min=%s, max=%s, value=%s, resolution=%d, mode=%d" %
        (unpacker.offset - base, self.number, self.label,
            self.min, self.max, self.value,
            self.resolution, self.mode))

which output

Valuator (size=48): number=0, label=164, min=<xcffib.xinput.FP3232 object at 0x7f78f7bb4a90>, max=<xcffib.xinput.FP3232 object at 0x7f78f7bb4b70>, value=<xcffib.xinput.FP3232 object at 0x7f78f7bb4ba8>, resolution=0, mode=2

Looking for the corresponding data from python-xlib, I found

{'type': 2, 'length': 11, 'sourceid': 12, 'number': 0, 'label': 164, 'min': -1.0, 'max': -1.0, 'value': 2157.6072313010227, 'resolution': 1, 'mode': 0}

The resolution and mode are different, which is enough to know there's a problem.

Narrowing to the first value that differed#

In order to see the difference in the min/max/value fields, I had to implement a __str__() method for xcffib.xinput.FP3232, which I did by copying python-xlib's FP3232.parse_value() and modifying it slightly:

def __str__(self):
    ret = float(self.integral)
    # optimised math.ldexp(float(frac), -32)
    ret += float(self.frac) * (1.0 / (1 << 32))
    return str(ret)

Then rerunning, I got the output

Valuator (size=48): number=0, label=164, min=65535.99998474121, max=4.7637149691581726e-07, value=-125790330.0, resolution=0, mode=2

showing that the issue occurs between label and min. Furthermore, the size of 48 when it should be 44 according to xinput.xml meant that I knew an extra 4 bytes of padding was being added somewhere.

Examining the sizes and offsets#

Looking up a few lines, I saw

if self.type == DeviceClassType.Valuator:
    self.number, self.label = unpacker.unpack("HI")
    self.min = FP3232(unpacker)
    unpacker.pad(FP3232)

Commenting out unpacker.pad(FP3232) get the size down to 46 but didn't get the right values computed:

Valuator (size=46): number=0, label=164, min=65535.99998474121, max=65535.0280456543, value=2022703104.000018, resolution=0, mode=0

And it's after the read of min, so it can't be the only problem anyway. After some fiddling around with what FP3232 did, I added print statements between each line of print(unpacker.offset - base):

Before number: 6
After label: 14
After min: 22
After pad after min: 24

Which showed that the line

self.number, self.label = unpacker.unpack("HI")

was advancing the offset by 14-6=8 bytes instead of expected 6 bytes for a 2-byte value followed by a 4-byte value.

To the bug#

That's when I went to check how unpacker.unpack() was computing the offset and saw that the formats were different. I added some additional print statements there to determine that struct.calcsize() was indeed computing 8 as the size when I expected 6. After reading the documentation on struct format strings, I reached the solution described above.

Comments

Have something to add? Post a comment by sending an email to comments@aweirdimagination.net. You may use Markdown for formatting.

There are no comments yet.