From 17c52926e793eafa4be56352bfa2f7454107d37f Mon Sep 17 00:00:00 2001 From: Jacob Dahl Date: Sun, 23 Aug 2026 12:01:39 -0600 Subject: [PATCH] feat(usb): add CDC/ACM suspend/resume check A USB device driver that reports CLASS_SUSPEND but never CLASS_RESUME leaves cdcacm_suspend()'s uart_connected(false) latched, after which serial.c refuses every board-side open() and write() on the CDC port with -ENOTCONN. The device stays enumerated, so nothing about the failure looks like a suspend bug. Linux hosts reach that state on their own, so the check only has to force it deterministically: each cycle re-arms runtime PM and confirms runtime_status actually reached suspended before judging anything, because a resumed device will not idle out again until PM is toggled. Bytes are counted over the whole read window and over its last second -- the first read after a broken resume still returns the stale CDC TX buffer, which on STM32H7 is a convincing 12 kB of nothing. Ported from a standalone script used to characterise https://github.com/apache/nuttx/pull/19936, which unmasks WKUP in eight DWC2-derived device drivers. Signed-off-by: Jacob Dahl --- Documentation/usage.rst | 55 +++ src/ntfc/cli/environment.py | 4 + src/ntfc/cli/main.py | 36 +- src/ntfc/commands/cmd_usbsuspend.py | 129 ++++++ src/ntfc/ext_commands.py | 2 + src/ntfc/lib/usb/suspend.py | 585 ++++++++++++++++++++++++++++ tests/cli/test_main_usbsuspend.py | 100 +++++ tests/lib/test_usb_suspend.py | 467 ++++++++++++++++++++++ 8 files changed, 1374 insertions(+), 4 deletions(-) create mode 100644 src/ntfc/commands/cmd_usbsuspend.py create mode 100644 src/ntfc/lib/usb/suspend.py create mode 100644 tests/cli/test_main_usbsuspend.py create mode 100644 tests/lib/test_usb_suspend.py diff --git a/Documentation/usage.rst b/Documentation/usage.rst index d92ada1..bc3eba6 100644 --- a/Documentation/usage.rst +++ b/Documentation/usage.rst @@ -157,6 +157,61 @@ Options: * ``--flash / --no-flash`` Flash image. Default: True. +``usb-suspend`` command +----------------------- + +Check that a NuttX USB CDC/ACM link survives Linux USB runtime suspend. +Implemented by :class:`ntfc.lib.usb.suspend.UsbSuspendCheck`. + +A device driver that reports ``CLASS_SUSPEND`` but never ``CLASS_RESUME`` +leaves ``cdcacm_suspend()``'s ``uart_connected(false)`` latched, after which +every board-side ``open()`` and ``write()`` on the CDC port returns +``-ENOTCONN``. The device stays enumerated, so the failure reads as a random +USB wedge. + +Each cycle forces a runtime suspend, resumes the device by opening the port, +and counts bytes over the read window and over its tail. The tail count is +what separates a working link from one that only flushed its stale CDC TX +buffer on resume. + +.. code-block:: bash + + python -m ntfc usb-suspend [OPTIONS] + +Requires a Linux host, ``sudo`` for two sysfs power attributes, a closed port +(an open port pins runtime PM, so the host never suspends), and a board that +transmits unprompted. Takes no ``--confpath``: nothing here needs a NuttX +configuration. + +Options: + +* ``-d, --device PATH`` - CDC/ACM port of the board. + Default: autodetect, which requires exactly one CDC/ACM port. + +* ``-n, --cycles INTEGER`` - Suspend/resume cycles to run. Default: 5. + +* ``--delay-ms INTEGER`` - Autosuspend delay to force. Default: 1000. + +* ``--baud INTEGER`` - Baud rate of the CDC/ACM port. Default: 115200. + +* ``--read-secs FLOAT`` - Read window per cycle. Default: 2.0. + +* ``--tail-secs FLOAT`` - Trailing part of the window that must carry data. + Default: 1.0. + +* ``--min-bytes INTEGER`` - Bytes required in the tail window. Default: 2000. + +* ``--console PATH`` - Board console, on a separate port, used to ask the + board itself whether its CDC port is writable after resume. + +* ``--console-baud INTEGER`` - Baud rate of that console. Default: 115200. + +* ``--cdc-path PATH`` - CDC path as the board sees it. + Default: ``/dev/ttyACM0``. + +Exit codes: 0 the link recovered from every suspend, 1 it did not, 2 the host +never suspended the device so nothing was exercised. + Log Management ============== diff --git a/src/ntfc/cli/environment.py b/src/ntfc/cli/environment.py index 891f3e4..697d9c1 100644 --- a/src/ntfc/cli/environment.py +++ b/src/ntfc/cli/environment.py @@ -58,6 +58,10 @@ class DEnvironmentData: modules: Optional[List[str]] = None select_individual_tests: Optional[List[int]] = None + # usb suspend check + runusbsuspend: bool = False + usbsuspend: Optional[Any] = None + # multi-session runmulti: bool = False manifest: Optional[str] = None diff --git a/src/ntfc/cli/main.py b/src/ntfc/cli/main.py index 9fde77a..3fdea03 100644 --- a/src/ntfc/cli/main.py +++ b/src/ntfc/cli/main.py @@ -25,7 +25,7 @@ import pprint import sys from collections.abc import Mapping -from typing import Any, Dict, List, Tuple +from typing import Any, Dict, List, Optional, Tuple import click import yaml # type: ignore @@ -33,6 +33,7 @@ from ntfc.builder import BuilderConfigError, NuttXBuilder from ntfc.cli.environment import Environment, pass_environment +from ntfc.lib.usb.suspend import UsbSuspendCheck, UsbSuspendError from ntfc.log.logger import logger from ntfc.multi import ManifestConfig, MultiSessionRunner from ntfc.plugins_loader import commands_list @@ -340,6 +341,34 @@ def multi_run(ctx: Environment) -> int: return runner.run() +def usbsuspend_run(ctx: Environment) -> int: + """Run the USB CDC/ACM suspend/resume check. + + :param ctx: CLI environment carrying the check configuration. + :return: Exit code (0 = pass, 1 = fail, 2 = inconclusive). + """ + assert ctx.usbsuspend is not None + try: + return UsbSuspendCheck(ctx.usbsuspend).run() + except UsbSuspendError as exc: + raise click.ClickException(str(exc)) from exc + + +def standalone_run(ctx: Environment) -> Optional[int]: + """Run a mode that needs no NuttX configuration of its own. + + :param ctx: CLI environment. + :return: Exit code, or None when no such mode was selected. + """ + if ctx.runmulti: + return multi_run(ctx) + + if ctx.runusbsuspend: + return usbsuspend_run(ctx) + + return None + + @pass_environment def cli_on_close(ctx: Environment) -> bool: """Handle all work on Click close.""" @@ -347,9 +376,8 @@ def cli_on_close(ctx: Environment) -> bool: # do nothing if help was called return True - # multi-session mode - if ctx.runmulti: - ret = multi_run(ctx) + ret = standalone_run(ctx) + if ret is not None: if ret != 0: exit(1) return True diff --git a/src/ntfc/commands/cmd_usbsuspend.py b/src/ntfc/commands/cmd_usbsuspend.py new file mode 100644 index 0000000..0de754b --- /dev/null +++ b/src/ntfc/commands/cmd_usbsuspend.py @@ -0,0 +1,129 @@ +############################################################################ +# SPDX-License-Identifier: Apache-2.0 +# +# Licensed to the Apache Software Foundation (ASF) under one or more +# contributor license agreements. See the NOTICE file distributed with +# this work for additional information regarding copyright ownership. The +# ASF licenses this file to you under the Apache License, Version 2.0 (the +# "License"); you may not use this file except in compliance with the +# License. You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT +# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the +# License for the specific language governing permissions and limitations +# under the License. +# +############################################################################ + +"""Module containing NTFC usb-suspend command.""" + +from typing import Optional + +import click + +from ntfc.cli.environment import Environment, pass_environment +from ntfc.lib.usb.suspend import UsbSuspendConfig + +############################################################################### +# Command: cmd_usbsuspend +############################################################################### + + +@click.command(name="usb-suspend") +@click.option( + "-d", + "--device", + default=None, + help="CDC/ACM port of the board. Default: autodetect", +) +@click.option( + "-n", + "--cycles", + type=int, + default=5, + help="Suspend/resume cycles to run. Default: 5", +) +@click.option( + "--delay-ms", + type=int, + default=1000, + help="Autosuspend delay to force. Default: 1000", +) +@click.option( + "--baud", + type=int, + default=115200, + help="Baud rate of the CDC/ACM port. Default: 115200", +) +@click.option( + "--read-secs", + type=float, + default=2.0, + help="Read window per cycle. Default: 2.0", +) +@click.option( + "--tail-secs", + type=float, + default=1.0, + help="Trailing part of the window that must carry data. Default: 1.0", +) +@click.option( + "--min-bytes", + type=int, + default=2000, + help="Bytes required in the tail window. Default: 2000", +) +@click.option( + "--console", + default=None, + help="Board console, to probe the port from the board after resume", +) +@click.option( + "--console-baud", + type=int, + default=115200, + help="Baud rate of that console. Default: 115200", +) +@click.option( + "--cdc-path", + default="/dev/ttyACM0", + help="CDC path as the board sees it. Default: /dev/ttyACM0", +) +@pass_environment +def cmd_usbsuspend( + ctx: Environment, + device: Optional[str], + cycles: int, + delay_ms: int, + baud: int, + read_secs: float, + tail_secs: float, + min_bytes: int, + console: Optional[str], + console_baud: int, + cdc_path: str, +) -> bool: + """Check that a USB CDC/ACM link survives host runtime suspend. + + Needs a Linux host, sudo for two sysfs power attributes, and a board + that transmits unprompted on the CDC port. Needs no NuttX + configuration, so it takes no --confpath. + """ + ctx.runusbsuspend = True + ctx.usbsuspend = UsbSuspendConfig( + device=device, + cycles=cycles, + delay_ms=delay_ms, + baud=baud, + read_secs=read_secs, + tail_secs=tail_secs, + min_bytes=min_bytes, + console=console, + console_baud=console_baud, + cdc_path=cdc_path, + ) + + return True diff --git a/src/ntfc/ext_commands.py b/src/ntfc/ext_commands.py index 89db75e..7c1f7f3 100644 --- a/src/ntfc/ext_commands.py +++ b/src/ntfc/ext_commands.py @@ -25,6 +25,7 @@ from ntfc.commands.cmd_build import cmd_build from ntfc.commands.cmd_collect import cmd_collect from ntfc.commands.cmd_test import cmd_test +from ntfc.commands.cmd_usbsuspend import cmd_usbsuspend if TYPE_CHECKING: import click @@ -33,4 +34,5 @@ cmd_build, cmd_collect, cmd_test, + cmd_usbsuspend, ] diff --git a/src/ntfc/lib/usb/suspend.py b/src/ntfc/lib/usb/suspend.py new file mode 100644 index 0000000..c83b6f0 --- /dev/null +++ b/src/ntfc/lib/usb/suspend.py @@ -0,0 +1,585 @@ +############################################################################ +# SPDX-License-Identifier: Apache-2.0 +# +# Licensed to the Apache Software Foundation (ASF) under one or more +# contributor license agreements. See the NOTICE file distributed with +# this work for additional information regarding copyright ownership. The +# ASF licenses this file to you under the Apache License, Version 2.0 (the +# "License"); you may not use this file except in compliance with the +# License. You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT +# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the +# License for the specific language governing permissions and limitations +# under the License. +# +############################################################################ + +"""Check that a NuttX USB CDC/ACM link survives Linux runtime suspend. + +A USB device driver that reports ``CLASS_SUSPEND`` but never +``CLASS_RESUME`` leaves ``cdcacm_suspend()``'s ``uart_connected(false)`` +latched, after which ``serial.c`` refuses every board-side ``open()`` and +``write()`` on the CDC port with ``-ENOTCONN``. The device stays +enumerated throughout, so the failure looks like a random USB wedge rather +than a deterministic one. + +Linux hosts reach that state on their own: with ``power/control=auto`` and +the usual ``autosuspend_delay_ms=2000``, closing the tty is enough. + +Each cycle forces a real runtime suspend, resumes the device by opening the +port, and measures what comes back. A link that only flushes its stale CDC +TX buffer is caught by the tail measurement: bytes are counted both over the +whole read window and over its last second. + +The board must transmit unprompted for this to measure anything -- a console +banner, a telemetry stream, anything periodic. + +Requires a Linux host and sudo for two sysfs power attributes. +""" + +import errno +import glob +import os +import subprocess +import time +from dataclasses import dataclass, field +from typing import Dict, List, Optional, Tuple + +import serial # type: ignore + +# Overridden by the unit tests; the kernel offers no other location. +SYSFS_TTY = "/sys/class/tty" +DEV_SERIAL_BY_ID = "/dev/serial/by-id" +PROCFS = "/proc" + +############################################################################### +# Class: UsbSuspendError +############################################################################### + + +class UsbSuspendError(Exception): + """Raised when the host cannot be brought into a measurable state.""" + + +############################################################################### +# Class: UsbSuspendConfig +############################################################################### + + +@dataclass +class UsbSuspendConfig: + """Parameters of one suspend/resume measurement run.""" + + device: Optional[str] = None + cycles: int = 5 + delay_ms: int = 1000 + read_secs: float = 2.0 + tail_secs: float = 1.0 + min_bytes: int = 2000 + baud: int = 115200 + console: Optional[str] = None + console_baud: int = 115200 + cdc_path: str = "/dev/ttyACM0" + + +############################################################################### +# Class: StreamRead +############################################################################### + + +@dataclass +class StreamRead: + """Bytes seen in a read window and in its trailing part.""" + + total: int = 0 + tail: int = 0 + error: str = "" + + +############################################################################### +# Class: CycleResult +############################################################################### + + +@dataclass +class CycleResult: + """Outcome of one suspend/resume cycle.""" + + suspended: bool = False + stream: StreamRead = field(default_factory=StreamRead) + note: str = "" + + +############################################################################### +# Function: read_attr +############################################################################### + + +def read_attr(base: str, name: str) -> Optional[str]: + """Read one sysfs attribute. + + :param base: directory holding the attribute + :param name: attribute file name + :return: stripped contents, or ``None`` when it is not readable + """ + try: + with open(os.path.join(base, name), encoding="utf-8") as handle: + return handle.read().strip() + except OSError: + return None + + +############################################################################### +# Function: usb_device_dir +############################################################################### + + +def usb_device_dir(tty_path: str) -> str: + """Map a tty device node to the sysfs directory of its USB device. + + :param tty_path: tty node, e.g. ``/dev/ttyACM0`` + :return: sysfs directory of the USB device owning that tty + :raises UsbSuspendError: when the tty is not backed by a USB device + """ + tty = os.path.basename(os.path.realpath(tty_path)) + link = os.path.join(SYSFS_TTY, tty, "device") + + if not os.path.exists(link): + raise UsbSuspendError(f"{tty_path} is not a tty backed by sysfs") + + node = os.path.realpath(link) + + # Walk up from the USB interface to the USB device that owns it. + while node != "/": + if os.path.exists(os.path.join(node, "idVendor")): + return node + + node = os.path.dirname(node) + + raise UsbSuspendError( + f"{tty_path} is not on a USB device (no idVendor in any parent)" + ) + + +############################################################################### +# Function: find_cdc_ports +############################################################################### + + +def find_cdc_ports() -> List[Tuple[str, str]]: + """Return ``(port, usb sysfs dir)`` for every CDC/ACM port present.""" + found = [] + + for path in sorted(glob.glob(os.path.join(DEV_SERIAL_BY_ID, "*"))): + if not os.path.basename(os.path.realpath(path)).startswith("ttyACM"): + continue + + try: + found.append((path, usb_device_dir(path))) + except UsbSuspendError: + continue + + return found + + +############################################################################### +# Function: autodetect +############################################################################### + + +def autodetect() -> str: + """Pick the CDC/ACM port when exactly one is present. + + :return: path of the only CDC/ACM port + :raises UsbSuspendError: when there is no port, or more than one + """ + found = find_cdc_ports() + + if not found: + raise UsbSuspendError("no CDC/ACM port found, pass --device") + + if len(found) > 1: + listing = "\n".join( + f" {path} ({read_attr(usbdir, 'product')})" + for path, usbdir in found + ) + raise UsbSuspendError( + f"several CDC/ACM ports present, pass --device:\n{listing}" + ) + + return found[0][0] + + +############################################################################### +# Function: port_holders +############################################################################### + + +def port_holders(dev: str) -> List[Tuple[str, str]]: + """Return ``(pid, command)`` of every process holding ``dev`` open.""" + real = os.path.realpath(dev) + holders = [] + + for entry in glob.glob(os.path.join(PROCFS, "[0-9]*", "fd", "*")): + try: + if os.readlink(entry) != real: + continue + + pid = entry.split(os.sep)[-3] + comm = os.path.join(PROCFS, pid, "comm") + + with open(comm, encoding="utf-8") as handle: + holders.append((pid, handle.read().strip())) + except OSError: + continue # process exited, or not ours to inspect + + return sorted(set(holders)) + + +############################################################################### +# Function: require_free_port +############################################################################### + + +def require_free_port(dev: str, baud: int) -> None: + """Reject a port that another process holds open. + + An open port pins runtime PM, so the host never suspends and the run + silently measures nothing. + + :param dev: CDC/ACM port to check + :param baud: baud rate used for the probe open + :raises UsbSuspendError: when the port is busy or cannot be opened + """ + try: + serial.Serial(os.path.realpath(dev), baud, timeout=0.2).close() + return + except OSError as exc: + if exc.errno != errno.EBUSY: + raise UsbSuspendError(f"cannot open {dev}: {exc}") from exc + + who = ", ".join(f"{name} (pid {pid})" for pid, name in port_holders(dev)) + raise UsbSuspendError( + f"{dev} is held open by {who or 'another process'}.\n" + "Close it first: an open port keeps the device active, so it " + "never suspends." + ) + + +############################################################################### +# Function: read_stream +############################################################################### + + +def read_stream( + dev: str, baud: int, seconds: float, tail_seconds: float +) -> StreamRead: + """Read the port for ``seconds`` and count what arrives. + + Opening the port is also what resumes a suspended device. + + :param dev: CDC/ACM port to read + :param baud: baud rate + :param seconds: length of the read window + :param tail_seconds: trailing part of the window counted separately + :return: byte counts, or an open error + """ + try: + port = serial.Serial(os.path.realpath(dev), baud, timeout=0.2) + except OSError as exc: + return StreamRead(error=str(exc)) + + start = time.time() + result = StreamRead() + + try: + while True: + now = time.time() - start + + if now >= seconds: + break + + chunk = port.read(4096) + result.total += len(chunk) + + if now >= seconds - tail_seconds: + result.tail += len(chunk) + finally: + port.close() + + return result + + +############################################################################### +# Function: console_probe +############################################################################### + + +def console_probe(console: str, baud: int, cdc_path: str) -> str: + """Ask the board itself whether its CDC port is writable. + + :param console: board console port, separate from the CDC port + :param baud: baud rate of that console + :param cdc_path: CDC path as the board sees it + :return: one-line verdict for the cycle report + """ + try: + port = serial.Serial(console, baud, timeout=0.2) + except OSError as exc: + return f"console unavailable ({exc})" + + out = b"" + + try: + port.write(b"\n") + port.flush() + time.sleep(0.3) + port.reset_input_buffer() + port.write(b"echo probe > " + cdc_path.encode() + b"\n") + port.flush() + + deadline = time.time() + 2.0 + + while time.time() < deadline: + chunk = port.read(4096) + + if chunk: + out += chunk + deadline = time.time() + 0.5 + finally: + port.close() + + text = out.decode(errors="replace") + + if "not connected" in text or "ENOTCONN" in text: + return "board-side open failed: -ENOTCONN" + + if "failed" in text: + return f"board-side open failed: {text.strip().splitlines()[-1]}" + + return "board-side open ok" + + +############################################################################### +# Class: UsbPower +############################################################################### + + +class UsbPower: + """Runtime PM knobs of one USB device, restored on exit.""" + + ATTRS = ("control", "autosuspend_delay_ms") + + def __init__(self, usbdir: str) -> None: + """Snapshot the runtime PM attributes of a USB device. + + :param usbdir: sysfs directory of the USB device + """ + self.path = os.path.join(usbdir, "power") + self.saved: Dict[str, Optional[str]] = { + key: self.get_attr(key) for key in self.ATTRS + } + + def get_attr(self, name: str) -> Optional[str]: + """Read one power attribute.""" + return read_attr(self.path, name) + + def set_attr(self, name: str, value: object) -> None: + """Write one power attribute through ``sudo tee``. + + :param name: attribute file name + :param value: value to write + :raises UsbSuspendError: when the write is refused + """ + target = os.path.join(self.path, name) + proc = subprocess.run( + ["sudo", "tee", target], + input=str(value).encode(), + stdout=subprocess.DEVNULL, + stderr=subprocess.PIPE, + check=False, + ) + + if proc.returncode != 0: + reason = proc.stderr.decode().strip() + raise UsbSuspendError(f"cannot write {target}: {reason}") + + def restore(self) -> None: + """Put the saved runtime PM attributes back.""" + for name, value in self.saved.items(): + if value is not None: + self.set_attr(name, value) + + def arm(self, delay_ms: int) -> None: + """Re-arm the autosuspend timer. + + Once resumed, a device will not idle out again on its own until + runtime PM is toggled, so every cycle has to re-allow it. + + :param delay_ms: autosuspend delay to force + """ + self.set_attr("autosuspend_delay_ms", delay_ms) + self.set_attr("control", "on") + self.set_attr("control", "auto") + + def wait_suspended(self, timeout: float) -> bool: + """Wait for ``runtime_status`` to reach ``suspended``. + + :param timeout: seconds to wait + :return: True when the device actually suspended + """ + deadline = time.time() + timeout + + while time.time() < deadline: + if self.get_attr("runtime_status") == "suspended": + return True + + time.sleep(0.1) + + return False + + +############################################################################### +# Class: UsbSuspendCheck +############################################################################### + + +class UsbSuspendCheck: + """Drive host runtime suspend cycles and judge the CDC/ACM link.""" + + def __init__(self, cfg: UsbSuspendConfig) -> None: + """Resolve the device and take its runtime PM state. + + :param cfg: parameters of the run + :raises UsbSuspendError: when no usable port could be claimed + """ + self.cfg = cfg + self.device = cfg.device or autodetect() + self.usbdir = usb_device_dir(self.device) + require_free_port(self.device, cfg.baud) + self.power = UsbPower(self.usbdir) + + def report_setup(self) -> None: + """Print what is being measured and how it was found.""" + print(f"device {self.device} -> {os.path.realpath(self.device)}") + print( + f"usb {os.path.basename(self.usbdir)} " + f"{read_attr(self.usbdir, 'idVendor')}:" + f"{read_attr(self.usbdir, 'idProduct')} " + f"{read_attr(self.usbdir, 'product')}" + ) + print( + f"power control={self.power.saved['control']} " + f"autosuspend_delay_ms=" + f"{self.power.saved['autosuspend_delay_ms']} (restored on exit)" + ) + print() + + def report_cycle(self, index: int, result: CycleResult) -> None: + """Print the outcome of one cycle. + + :param index: 1-based cycle number + :param result: what that cycle measured + """ + if result.stream.error: + print( + f"[{index}] suspended={str(result.suspended):<5} " + f"OPEN FAILED: {result.stream.error}" + ) + return + + print( + f"[{index}] suspended={str(result.suspended):<5} " + f"read={result.stream.total:<7} " + f"tail={result.stream.tail:<7} {result.note}" + ) + + def cycle(self) -> CycleResult: + """Force one suspend, resume by opening the port, and measure.""" + cfg = self.cfg + self.power.arm(cfg.delay_ms) + suspended = self.power.wait_suspended(cfg.delay_ms / 1000.0 + 6.0) + + stream = read_stream( + self.device, cfg.baud, cfg.read_secs, cfg.tail_secs + ) + note = "" + + if cfg.console and suspended: + # -ENOTCONN *during* suspend is correct on any build; the + # defect is that it outlives the resume. Pin the device + # active so the board is asked about the resumed state. + self.power.set_attr("control", "on") + note = console_probe(cfg.console, cfg.console_baud, cfg.cdc_path) + + return CycleResult(suspended, stream, note) + + def verdict(self, results: List[CycleResult]) -> int: + """Judge the collected cycles. + + :param results: one entry per cycle + :return: 0 pass, 1 fail, 2 inconclusive + """ + tested = [res for res in results if res.suspended] + good = [res for res in tested if res.stream.tail >= self.cfg.min_bytes] + + print() + + if not tested: + print( + "INCONCLUSIVE: the host never suspended the device, so the " + "bug was never exercised." + ) + print( + f"Check {self.usbdir}/power/runtime_usage; something holds " + "a runtime PM reference." + ) + return 2 + + print(f"cycles with a verified suspend: {len(tested)}/{len(results)}") + print( + "of those, still streaming after resume: " + f"{len(good)}/{len(tested)}" + ) + print() + + if len(good) == len(tested): + print("PASS: the link recovered from every suspend.") + return 0 + + print("FAIL: the link died after suspend and did not come back.") + print( + "Expected when the resume event never reaches the class driver: " + "the first cycle can still flush the stale CDC TX buffer, later " + "cycles read nothing." + ) + return 1 + + def run(self) -> int: + """Run every cycle and report a verdict. + + :return: 0 pass, 1 fail, 2 inconclusive + """ + self.report_setup() + results: List[CycleResult] = [] + + try: + for index in range(1, self.cfg.cycles + 1): + result = self.cycle() + self.report_cycle(index, result) + results.append(result) + finally: + self.power.restore() + print() + print( + "power restored to " + f"control={self.power.get_attr('control')} " + "autosuspend_delay_ms=" + f"{self.power.get_attr('autosuspend_delay_ms')}" + ) + + return self.verdict(results) diff --git a/tests/cli/test_main_usbsuspend.py b/tests/cli/test_main_usbsuspend.py new file mode 100644 index 0000000..74d5c6d --- /dev/null +++ b/tests/cli/test_main_usbsuspend.py @@ -0,0 +1,100 @@ +############################################################################ +# SPDX-License-Identifier: Apache-2.0 +# +# Licensed to the Apache Software Foundation (ASF) under one or more +# contributor license agreements. See the NOTICE file distributed with +# this work for additional information regarding copyright ownership. The +# ASF licenses this file to you under the Apache License, Version 2.0 (the +# "License"); you may not use this file except in compliance with the +# License. You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT +# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the +# License for the specific language governing permissions and limitations +# under the License. +# +############################################################################ + +import pytest +from click.testing import CliRunner + +from ntfc.cli.main import main +from ntfc.lib.usb.suspend import UsbSuspendError + +ARGS = [ + "usb-suspend", + "--device=/dev/ttyACM0", + "--cycles=3", + "--delay-ms=500", + "--baud=57600", + "--read-secs=1.0", + "--tail-secs=0.5", + "--min-bytes=100", + "--console=/dev/ttyUSB0", + "--console-baud=57600", + "--cdc-path=/dev/ttyACM1", +] + + +@pytest.fixture +def runner(): + return CliRunner() + + +def fake_check(ret=None, exc=None): + class FakeUsbSuspendCheck: + def __init__(self, cfg): + if exc is not None: + raise exc + self.cfg = cfg + + def run(self): + return ret + + return FakeUsbSuspendCheck + + +def test_usbsuspend_pass(runner, monkeypatch): + captured = {} + + class Recorder(fake_check(ret=0)): + def __init__(self, cfg): + super().__init__(cfg) + captured["cfg"] = cfg + + monkeypatch.setattr("ntfc.cli.main.UsbSuspendCheck", Recorder) + + result = runner.invoke(main, ARGS) + + assert result.exit_code == 0 + cfg = captured["cfg"] + assert cfg.device == "/dev/ttyACM0" + assert cfg.cycles == 3 + assert cfg.delay_ms == 500 + assert cfg.baud == 57600 + assert cfg.read_secs == 1.0 + assert cfg.tail_secs == 0.5 + assert cfg.min_bytes == 100 + assert cfg.console == "/dev/ttyUSB0" + assert cfg.console_baud == 57600 + assert cfg.cdc_path == "/dev/ttyACM1" + + +def test_usbsuspend_fail(runner, monkeypatch): + monkeypatch.setattr("ntfc.cli.main.UsbSuspendCheck", fake_check(ret=1)) + assert runner.invoke(main, ["usb-suspend"]).exit_code == 1 + + +def test_usbsuspend_error(runner, monkeypatch): + monkeypatch.setattr( + "ntfc.cli.main.UsbSuspendCheck", + fake_check(exc=UsbSuspendError("no CDC/ACM port found")), + ) + + result = runner.invoke(main, ["usb-suspend"]) + + assert result.exit_code == 1 + assert "no CDC/ACM port found" in result.output diff --git a/tests/lib/test_usb_suspend.py b/tests/lib/test_usb_suspend.py new file mode 100644 index 0000000..20f2840 --- /dev/null +++ b/tests/lib/test_usb_suspend.py @@ -0,0 +1,467 @@ +############################################################################ +# SPDX-License-Identifier: Apache-2.0 +# +# Licensed to the Apache Software Foundation (ASF) under one or more +# contributor license agreements. See the NOTICE file distributed with +# this work for additional information regarding copyright ownership. The +# ASF licenses this file to you under the Apache License, Version 2.0 (the +# "License"); you may not use this file except in compliance with the +# License. You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT +# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the +# License for the specific language governing permissions and limitations +# under the License. +# +############################################################################ + +import errno +import os +import types + +import pytest + +from ntfc.lib.usb import suspend +from ntfc.lib.usb.suspend import ( + CycleResult, + StreamRead, + UsbPower, + UsbSuspendCheck, + UsbSuspendConfig, + UsbSuspendError, + autodetect, + console_probe, + find_cdc_ports, + port_holders, + read_attr, + read_stream, + require_free_port, + usb_device_dir, +) + + +class FakeClock: + """Deterministic replacement for the module ``time`` import.""" + + def __init__(self, step=0.5): + self.now = 0.0 + self.step = step + + def time(self): + value = self.now + self.now += self.step + return value + + def sleep(self, seconds): + self.now += seconds + + +class FakePort: + def __init__(self, chunks=None): + self.chunks = list(chunks or []) + self.written = b"" + self.closed = False + self.flushed = 0 + self.reset = 0 + + def read(self, _size): + return self.chunks.pop(0) if self.chunks else b"" + + def write(self, data): + self.written += data + + def flush(self): + self.flushed += 1 + + def reset_input_buffer(self): + self.reset += 1 + + def close(self): + self.closed = True + + +def fake_serial(port=None, error=None): + """Build a stand-in for the ``serial`` module.""" + + def factory(*_args, **_kwargs): + if error is not None: + raise error + return port if port is not None else FakePort() + + return types.SimpleNamespace(Serial=factory) + + +def write_file(path, text): + os.makedirs(os.path.dirname(path), exist_ok=True) + with open(path, "w", encoding="utf-8") as handle: + handle.write(text) + + +@pytest.fixture +def sysfs(tmp_path, monkeypatch): + """Build a fake sysfs/devfs holding one CDC/ACM device.""" + root = str(tmp_path) + usbdir = os.path.join(root, "sys/devices/usb1/1-5") + iface = os.path.join(usbdir, "1-5:1.0") + + write_file(os.path.join(usbdir, "idVendor"), "3185\n") + write_file(os.path.join(usbdir, "idProduct"), "0039\n") + write_file(os.path.join(usbdir, "product"), "ARK FMU v6X.x\n") + write_file(os.path.join(usbdir, "power/control"), "auto\n") + write_file(os.path.join(usbdir, "power/autosuspend_delay_ms"), "2000\n") + write_file(os.path.join(usbdir, "power/runtime_status"), "active\n") + os.makedirs(iface, exist_ok=True) + + tty = os.path.join(root, "sys/class/tty/ttyACM0") + os.makedirs(tty, exist_ok=True) + os.symlink(iface, os.path.join(tty, "device")) + + dev = os.path.join(root, "dev/ttyACM0") + write_file(dev, "") + + byid = os.path.join(root, "dev/serial/by-id") + os.makedirs(byid, exist_ok=True) + os.symlink(dev, os.path.join(byid, "usb-ARK_FMU_v6X-if00")) + + monkeypatch.setattr( + suspend, "SYSFS_TTY", os.path.join(root, "sys/class/tty") + ) + monkeypatch.setattr(suspend, "DEV_SERIAL_BY_ID", byid) + monkeypatch.setattr(suspend, "PROCFS", os.path.join(root, "proc")) + + return types.SimpleNamespace( + root=root, usbdir=usbdir, dev=dev, byid=byid, tty=tty + ) + + +############################################################################### +# sysfs helpers +############################################################################### + + +def test_read_attr(sysfs): + assert read_attr(sysfs.usbdir, "idVendor") == "3185" + assert read_attr(sysfs.usbdir, "nosuchattr") is None + + +def test_usb_device_dir(sysfs): + assert usb_device_dir(sysfs.dev) == sysfs.usbdir + + +def test_usb_device_dir_not_a_tty(sysfs): + with pytest.raises(UsbSuspendError, match="not a tty backed by sysfs"): + usb_device_dir(os.path.join(sysfs.root, "dev/ttyS9")) + + +def test_usb_device_dir_not_on_usb(sysfs): + plain = os.path.join(sysfs.root, "sys/devices/platform/serial0") + os.makedirs(plain, exist_ok=True) + tty = os.path.join(sysfs.root, "sys/class/tty/ttyS0") + os.makedirs(tty, exist_ok=True) + os.symlink(plain, os.path.join(tty, "device")) + + dev = os.path.join(sysfs.root, "dev/ttyS0") + write_file(dev, "") + + with pytest.raises(UsbSuspendError, match="no idVendor in any parent"): + usb_device_dir(dev) + + +def test_find_cdc_ports_skips_non_cdc_and_non_usb(sysfs): + # A non-CDC port: filtered on the tty name alone. + other = os.path.join(sysfs.root, "dev/ttyUSB0") + write_file(other, "") + os.symlink(other, os.path.join(sysfs.byid, "usb-FTDI-if00")) + + # A CDC port that is not backed by sysfs: dropped by usb_device_dir(). + orphan = os.path.join(sysfs.root, "dev/ttyACM9") + write_file(orphan, "") + os.symlink(orphan, os.path.join(sysfs.byid, "usb-Orphan-if00")) + + assert find_cdc_ports() == [ + (os.path.join(sysfs.byid, "usb-ARK_FMU_v6X-if00"), sysfs.usbdir) + ] + + +def test_autodetect(sysfs): + assert autodetect() == os.path.join(sysfs.byid, "usb-ARK_FMU_v6X-if00") + + +def test_autodetect_nothing(sysfs, monkeypatch): + monkeypatch.setattr( + suspend, "DEV_SERIAL_BY_ID", str(sysfs.root) + "/empty" + ) + with pytest.raises(UsbSuspendError, match="no CDC/ACM port found"): + autodetect() + + +def test_autodetect_ambiguous(sysfs): + second = os.path.join(sysfs.root, "sys/class/tty/ttyACM1") + os.makedirs(second, exist_ok=True) + os.symlink( + os.path.join(sysfs.usbdir, "1-5:1.0"), os.path.join(second, "device") + ) + + dev = os.path.join(sysfs.root, "dev/ttyACM1") + write_file(dev, "") + os.symlink(dev, os.path.join(sysfs.byid, "usb-Second-if00")) + + with pytest.raises(UsbSuspendError, match="ARK FMU v6X.x"): + autodetect() + + +############################################################################### +# port ownership +############################################################################### + + +def test_port_holders(sysfs): + proc = os.path.join(sysfs.root, "proc") + real = os.path.realpath(sysfs.dev) + + os.makedirs(os.path.join(proc, "123/fd"), exist_ok=True) + os.symlink(real, os.path.join(proc, "123/fd/3")) + write_file(os.path.join(proc, "123/comm"), "cat\n") + + # Exited between the readlink() and the comm read. + os.makedirs(os.path.join(proc, "456/fd"), exist_ok=True) + os.symlink(real, os.path.join(proc, "456/fd/3")) + + # Holds something else. + os.makedirs(os.path.join(proc, "789/fd"), exist_ok=True) + os.symlink("/dev/null", os.path.join(proc, "789/fd/1")) + + # Not a symlink at all. + write_file(os.path.join(proc, "999/fd/0"), "") + + assert port_holders(sysfs.dev) == [("123", "cat")] + + +def test_require_free_port_ok(sysfs, monkeypatch): + monkeypatch.setattr(suspend, "serial", fake_serial()) + require_free_port(sysfs.dev, 115200) + + +def test_require_free_port_busy(sysfs, monkeypatch): + monkeypatch.setattr( + suspend, "serial", fake_serial(error=OSError(errno.EBUSY, "busy")) + ) + with pytest.raises(UsbSuspendError, match="held open by another process"): + require_free_port(sysfs.dev, 115200) + + +def test_require_free_port_unopenable(sysfs, monkeypatch): + monkeypatch.setattr( + suspend, "serial", fake_serial(error=OSError(errno.EACCES, "denied")) + ) + with pytest.raises(UsbSuspendError, match="cannot open"): + require_free_port(sysfs.dev, 115200) + + +############################################################################### +# measurement +############################################################################### + + +def test_read_stream_counts_tail(sysfs, monkeypatch): + port = FakePort([b"a" * 100, b"b" * 200, b"c" * 300]) + monkeypatch.setattr(suspend, "serial", fake_serial(port=port)) + monkeypatch.setattr(suspend, "time", FakeClock()) + + result = read_stream(sysfs.dev, 115200, 2.0, 1.0) + + assert result.total == 600 + assert result.tail == 500 + assert result.error == "" + assert port.closed + + +def test_read_stream_open_error(sysfs, monkeypatch): + monkeypatch.setattr(suspend, "serial", fake_serial(error=OSError("gone"))) + assert read_stream(sysfs.dev, 115200, 2.0, 1.0).error == "gone" + + +def test_console_probe_ok(monkeypatch): + port = FakePort([b"nsh> echo probe > /dev/ttyACM0\nnsh> "]) + monkeypatch.setattr(suspend, "serial", fake_serial(port=port)) + monkeypatch.setattr(suspend, "time", FakeClock()) + + assert console_probe("/dev/ttyUSB0", 57600, "/dev/ttyACM0") == ( + "board-side open ok" + ) + assert b"echo probe > /dev/ttyACM0\n" in port.written + assert port.closed + + +@pytest.mark.parametrize( + "reply", + [ + b"nsh: echo: open failed: Transport endpoint is not connected\n", + b"cdcacm: ENOTCONN\n", + ], +) +def test_console_probe_enotconn(monkeypatch, reply): + monkeypatch.setattr(suspend, "serial", fake_serial(port=FakePort([reply]))) + monkeypatch.setattr(suspend, "time", FakeClock()) + + assert console_probe("/dev/ttyUSB0", 57600, "/dev/ttyACM0") == ( + "board-side open failed: -ENOTCONN" + ) + + +def test_console_probe_other_failure(monkeypatch): + port = FakePort([b"nsh: echo: open failed: No such file\n"]) + monkeypatch.setattr(suspend, "serial", fake_serial(port=port)) + monkeypatch.setattr(suspend, "time", FakeClock()) + + assert console_probe("/dev/ttyUSB0", 57600, "/dev/ttyACM0").startswith( + "board-side open failed: nsh:" + ) + + +def test_console_probe_unavailable(monkeypatch): + monkeypatch.setattr(suspend, "serial", fake_serial(error=OSError("nope"))) + assert console_probe("/dev/ttyUSB0", 57600, "/dev/ttyACM0") == ( + "console unavailable (nope)" + ) + + +############################################################################### +# runtime PM +############################################################################### + + +class FakeRun: + def __init__(self, returncode=0, stderr=b""): + self.returncode = returncode + self.stderr = stderr + self.calls = [] + + def __call__(self, cmd, **kwargs): + payload = kwargs["input"].decode() + self.calls.append((cmd[-1], payload)) + if self.returncode == 0: + write_file(cmd[-1], payload) + return types.SimpleNamespace( + returncode=self.returncode, stderr=self.stderr + ) + + +def test_usbpower_get_set_restore(sysfs, monkeypatch): + run = FakeRun() + monkeypatch.setattr(suspend.subprocess, "run", run) + + power = UsbPower(sysfs.usbdir) + assert power.saved == {"control": "auto", "autosuspend_delay_ms": "2000"} + + power.set_attr("control", "on") + assert power.get_attr("control") == "on" + + # A missing attribute is never restored. + power.saved["nosuchattr"] = None + power.restore() + assert power.get_attr("control") == "auto" + assert "nosuchattr" not in [name for name, _ in run.calls] + + +def test_usbpower_set_refused(sysfs, monkeypatch): + monkeypatch.setattr( + suspend.subprocess, "run", FakeRun(1, b"tee: Permission denied\n") + ) + with pytest.raises(UsbSuspendError, match="Permission denied"): + UsbPower(sysfs.usbdir).set_attr("control", "on") + + +def test_usbpower_arm(sysfs, monkeypatch): + run = FakeRun() + monkeypatch.setattr(suspend.subprocess, "run", run) + + UsbPower(sysfs.usbdir).arm(1000) + + assert [value for _, value in run.calls] == ["1000", "on", "auto"] + + +def test_usbpower_wait_suspended(sysfs, monkeypatch): + monkeypatch.setattr(suspend, "time", FakeClock()) + power = UsbPower(sysfs.usbdir) + + assert power.wait_suspended(0.0) is False + + write_file(os.path.join(sysfs.usbdir, "power/runtime_status"), "suspended") + assert power.wait_suspended(10.0) is True + + +############################################################################### +# the check itself +############################################################################### + + +@pytest.fixture +def check(sysfs, monkeypatch): + monkeypatch.setattr(suspend, "serial", fake_serial()) + monkeypatch.setattr(suspend, "time", FakeClock()) + monkeypatch.setattr(suspend.subprocess, "run", FakeRun()) + return UsbSuspendCheck(UsbSuspendConfig(device=sysfs.dev, cycles=2)) + + +def test_check_autodetects_device(sysfs, monkeypatch): + monkeypatch.setattr(suspend, "serial", fake_serial()) + check = UsbSuspendCheck(UsbSuspendConfig()) + assert check.usbdir == sysfs.usbdir + + +def test_check_report(check, capsys): + check.report_setup() + check.report_cycle(1, CycleResult(True, StreamRead(40898, 23316), "ok")) + check.report_cycle(2, CycleResult(False, StreamRead(error="EIO"))) + + out = capsys.readouterr().out + assert "3185:0039 ARK FMU v6X.x" in out + assert "read=40898 tail=23316 ok" in out + assert "OPEN FAILED: EIO" in out + + +def test_check_cycle_probes_console(check, sysfs, monkeypatch): + write_file(os.path.join(sysfs.usbdir, "power/runtime_status"), "suspended") + check.cfg.console = "/dev/ttyUSB0" + + result = check.cycle() + + assert result.suspended is True + assert result.note == "board-side open ok" + + +def test_check_cycle_without_console(check): + assert check.cycle().note == "" + + +def test_check_verdict_inconclusive(check, capsys): + assert check.verdict([CycleResult(False)]) == 2 + assert "INCONCLUSIVE" in capsys.readouterr().out + + +def test_check_verdict_pass(check, capsys): + results = [CycleResult(True, StreamRead(40898, 23316)) for _ in range(2)] + assert check.verdict(results) == 0 + assert "PASS" in capsys.readouterr().out + + +def test_check_verdict_fail(check, capsys): + results = [ + CycleResult(True, StreamRead(12179, 0)), + CycleResult(True, StreamRead(0, 0)), + ] + assert check.verdict(results) == 1 + assert "FAIL" in capsys.readouterr().out + + +def test_check_run_restores_power(check, sysfs, capsys): + write_file(os.path.join(sysfs.usbdir, "power/runtime_status"), "suspended") + + assert check.run() == 1 + + assert check.power.get_attr("control") == "auto" + assert "restored to control=auto" in capsys.readouterr().out