summaryrefslogtreecommitdiff
path: root/test/hil/mtp_test.py
blob: 92d54bdbebc2b0557edd874f1a881286ec9e59b6 (plain)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
#!/usr/bin/env python3
# SPDX-License-Identifier: MIT
# One MTP test session for one board, in a disposable process. Every libmtp call is
# synchronous ctypes in our own address space and blocks in a usbfs ioctl in D state on
# a wedged device, where not even SIGKILL is delivered — so the session must be
# something the harness can abandon: hil_test.test_device_mtp runs it under
# hil_util.run_cmd (killpg + bounded reap, rc 124 on timeout). Imports stay stdlib +
# pymtp: nothing here may pull in the harness.
#
# Exit 0 on a fully passing session; 1 with the failure on stdout/stderr otherwise.
import argparse
import ctypes
import glob
import hashlib
import os
import signal
import subprocess
import sys
import threading
import time

sys.path.append(os.path.dirname(os.path.abspath(__file__)))  # PYTHONSAFEPATH drops it
# -- APPEND so PYTHONPATH still wins (the tests steer a fake pymtp that way)

from pathlib import Path
from pymtp import LIBMTP_DeviceEntry, LIBMTP_RawDevice, MTP

FILE1_EXPECT = b'TinyUSB MTP Filesystem example'
FILE2_MD5_EXPECT = '40ef23fc2891018d41a05d4a0d5f822f'  # md5sum of logo.png


# Real paths by default; the offline tests point these at a fixture tree, the same way
# they steer the pymtp fake through FAKE_PYMTP_*.
# The one test seam: '' in production, a tmpdir in the offline tests, which mirror the
# real layout beneath it. This runs as a SUBPROCESS (a libmtp call blocked in a usbfs
# ioctl hangs its thread forever, so the session must be somewhere killable), and neither
# monkeypatching nor import shadowing crosses that boundary -- unlike the fake pymtp,
# which the tests inject through PYTHONPATH alone.
_ROOT = os.environ.get('HIL_MTP_FAKE_ROOT', '')
_MARKER_GLOB = f'{_ROOT}/dev/libmtp-*'
_SYS_USB = Path(f'{_ROOT}/sys/bus/usb/devices')
_USB_DEV = Path(f'{_ROOT}/dev/bus/usb')


def _bounded_read(path, grace: float = 2.0):
    """Read a sysfs attribute with a wall-clock bound, or return None.

    `serial` is served under the device lock a wedged usbfs ioctl holds, and EVERY MTP DUT
    is cafe:4017 -- so the vid/pid filter below cannot rule out a wedged NEIGHBOUR, and an
    unbounded read of its serial would burn this session's whole budget and report a
    healthy board as wedged. Stdlib only by design (this file never imports the harness),
    so this is a small local twin of hil_util.read_sysfs.
    """
    out = {}

    def _read():
        try:
            out['v'] = path.read_text().strip()
        except OSError:
            pass

    t = threading.Thread(target=_read, daemon=True)
    t.start()
    t.join(grace)
    return out.get('v')


def _ready_marker(uid: str):
    """(busnum, devnum) of the udev-ready MTP device with this serial, or None.

    /dev/libmtp-<sysname> is published by libmtp-runtime AFTER its synchronous mtp-probe
    accepts the device, so this set is both small and ready -- unlike a sysfs-wide scan,
    which races re-enumerations from other boards' jobs. Requires the libmtp-runtime
    package.
    """
    for marker_name in glob.glob(_MARKER_GLOB):
        marker = Path(marker_name)
        try:
            dev = _SYS_USB / marker.name[len('libmtp-'):]
            # vid/pid first: lock-free descriptor fields, so they rule out every other
            # device before the `serial` read, which the kernel serves under the device
            # lock a wedged usbfs ioctl would hold
            if ((dev / 'idVendor').read_text().strip() != 'cafe'
                    or (dev / 'idProduct').read_text().strip() != '4017'):
                continue
            # bounded: this one CAN block, and a wedged neighbour shares the vid/pid above
            serial = _bounded_read(dev / 'serial')
            if serial is None or serial.lower() != uid.lower():
                continue
            busnum = int((dev / 'busnum').read_text())
            devnum = int((dev / 'devnum').read_text())
            node = _USB_DEV / f'{busnum:03d}' / f'{devnum:03d}'
            if marker.resolve(strict=True) != node or not os.access(node, os.R_OK | os.W_OK):
                continue
            return busnum, devnum
        except (OSError, ValueError):
            # a marker can vanish while another board flashes: not our device's problem
            continue
    return None


def _gvfs_unmount(uid: str, deadline: float) -> None:
    """Drop any gvfs claim on this device, immediately before opening it.

    Called only once the udev marker exists. gvfs claims an MTP device AFTER udev
    probing, so before the marker there is nothing to unmount: an earlier call is a
    guaranteed no-op that still forks a process, and it leaves the gap between the
    unmount and the open unprotected -- the hang this exists to prevent. Per-iteration
    calls also forked one gio per second of the enumeration budget.
    """
    # Popen, not run(timeout=): run's post-timeout reap is an unbounded wait(), and a gio
    # blocked in D state on a wedged usbfs node does not die on SIGKILL, so run(timeout=2)
    # can hang for good. Bounded by at most HALF of what is LEFT of our own budget, never
    # a fixed sub-bound: the parent gives us --timeout 8 (4 on a retry), so anything larger
    # collapsed the poll loop to one attempt and made a slow gio look like a wedged session.
    gio_bound = max(0.5, min(3.0, (deadline - time.monotonic()) / 2))
    try:
        # argv, not shell=True: uid comes from a hand-edited roster and is board firmware
        # output, so a space or $(...) would unmount the wrong URI (leaving the gvfs mount
        # held) or run as us.
        gio = subprocess.Popen(['gio', 'mount', '-u',
                                f'mtp://TinyUsb_TinyUsb_Device_{uid}/'],
                               stdout=subprocess.DEVNULL,
                               stderr=subprocess.DEVNULL, start_new_session=True)
    except OSError:
        # glib2.0-bin absent (ci.lan has no gio at all): nothing holds a gvfs mount
        # either, so go straight on to the open.
        return
    try:
        gio.wait(timeout=gio_bound)
    except subprocess.TimeoutExpired:
        try:
            os.killpg(gio.pid, signal.SIGKILL)
        except OSError:
            gio.kill()
        try:
            gio.wait(timeout=2)     # reap it: an abandoned gio leaves a zombie
        except subprocess.TimeoutExpired:
            pass
        print('gio unmount timed out; continuing', file=sys.stderr)


def open_mtp_dev(uid: str, timeout: float):
    mtp = MTP()
    deadline = time.monotonic() + timeout
    while True:
        try:
            # pymtp raises USB_LAYER/PTP_LAYER/GENERAL/AlreadyConnected on a board still
            # settling right after a flash; an unguarded raise would skip the rest of the
            # enumeration budget (and the disconnect) instead of retrying.
            #
            # Never detect_devices(): that PROBES every MTP device on the rig, so a board
            # still initialising in a parallel job answers our scan (the race #3790 fixed).
            # libmtp-runtime publishes /dev/libmtp-<sysname> only after its own mtp-probe
            # has accepted a device, so start from that small, ready-only set and open OUR
            # device directly by bus/dev address.
            target = _ready_marker(uid)
            if target:
                # ready first, THEN unmount, then open -- see _gvfs_unmount
                _gvfs_unmount(uid, deadline)
                busnum, devnum = target
                # TinyUSB needs no libmtp quirks, so the raw entry can be built here
                entry = LIBMTP_DeviceEntry(None, 0xcafe, None, 0x4017, 0)
                raw = LIBMTP_RawDevice(entry, busnum, devnum)
                mtp.device = mtp.mtp.LIBMTP_Open_Raw_Device(ctypes.byref(raw))
                if mtp.device:
                    serial = mtp.get_serialnumber()
                    if (serial.decode('utf-8') if serial else '').lower() == uid.lower():
                        return mtp
                    mtp.disconnect()
        except Exception as e:
            print(f'mtp poll: {type(e).__name__}: {e}', file=sys.stderr)
            # only when a device was actually opened: pymtp's `self.device == None`
            # guard does NOT catch a ctypes NULL pointer (falsy, but != None), so
            # disconnecting blindly calls LIBMTP_Release_Device(NULL)
            if getattr(mtp, 'device', None):
                try:
                    mtp.disconnect()
                except Exception:
                    pass
                mtp.device = None
        if time.monotonic() >= deadline:
            return None
        time.sleep(1)


def run_session(uid: str, timeout: float) -> int:
    mtp = open_mtp_dev(uid, timeout)
    if mtp is None or mtp.device is None:
        print('MTP device not found')
        return 1

    try:
        assert b"TinyUSB" == mtp.get_manufacturer(), 'MTP wrong manufacturer'
        assert b"MTP Example" == mtp.get_modelname(), 'MTP wrong model'
        assert b'1.0' == mtp.get_deviceversion(), 'MTP wrong version'
        assert b'TinyUSB MTP' == mtp.get_devicename(), 'MTP wrong device name'

        f1 = uid.encode("utf-8") + b'_file1'
        f2 = uid.encode("utf-8") + b'_file2'
        f3 = uid.encode("utf-8") + b'_file3'
        mtp.get_file_to_file(1, f1)
        with open(f1, 'rb') as file:
            f1_data = file.read()
            os.remove(f1)
            assert f1_data == FILE1_EXPECT, 'MTP file1 wrong data'
        mtp.get_file_to_file(2, f2)
        with open(f2, 'rb') as file:
            f2_data = file.read()
            os.remove(f2)
            assert FILE2_MD5_EXPECT == hashlib.md5(f2_data).hexdigest(), 'MTP file2 wrong data'
        with open(f3, "wb") as file:
            # 1524-byte payload + 12-byte MTP header = 3 full 512-byte buffers, so this
            # exercises delivery of the final OUT payload before its ZLP. Deliberate and
            # FIXED: a random size hits that boundary in ~0.2% of runs, which is not a test
            # of it. Deterministic content so a mismatch is reproducible.
            f3_data = bytes((i % 251) + 1 for i in range(1524))
            file.write(f3_data)
            file.close()
            fid = mtp.send_file_from_file(f3, b'file3')
            f3_readback = f3 + b'_readback'
            mtp.get_file_to_file(fid, f3_readback)
            with open(f3_readback, 'rb') as f:
                f3_rb_data = f.read()
                os.remove(f3_readback)
                assert f3_rb_data == f3_data, 'MTP file3 wrong data'
            os.remove(f3)
            mtp.delete_object(fid)
    except AssertionError as e:
        print(e)
        return 1
    finally:
        mtp.disconnect()
    return 0


def main() -> int:
    parser = argparse.ArgumentParser()
    parser.add_argument('--uid', required=True, help='board_get_unique_id serial to match')
    parser.add_argument('--timeout', type=float, default=30, help='enumeration wait budget (s)')
    args = parser.parse_args()
    return run_session(args.uid, args.timeout)


if __name__ == '__main__':
    sys.exit(main())