summaryrefslogtreecommitdiff
path: root/test/hil/mtp_test.py
diff options
context:
space:
mode:
Diffstat (limited to 'test/hil/mtp_test.py')
-rw-r--r--test/hil/mtp_test.py246
1 files changed, 246 insertions, 0 deletions
diff --git a/test/hil/mtp_test.py b/test/hil/mtp_test.py
new file mode 100644
index 000000000..92d54bdbe
--- /dev/null
+++ b/test/hil/mtp_test.py
@@ -0,0 +1,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())