This is an automated email from the ASF dual-hosted git repository.
acassis pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/nuttx-ntfc-testing.git
The following commit(s) were added to refs/heads/main by this push:
new 28cfe32 test(usb): add CDC/ACM suspend/resume check
28cfe32 is described below
commit 28cfe32ecb9886edeea1ec098655e61b84fb99c6
Author: Jacob Dahl <[email protected]>
AuthorDate: Sun Aug 23 12:52:29 2026 -0600
test(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.
Written to characterise https://github.com/apache/nuttx/pull/19936, which
unmasks WKUP in eight DWC2-derived device drivers.
Signed-off-by: Jacob Dahl <[email protected]>
---
driver/usb/test_driver_usb_integration.py | 405 ++++++++++++++++++++++++++++++
1 file changed, 405 insertions(+)
diff --git a/driver/usb/test_driver_usb_integration.py
b/driver/usb/test_driver_usb_integration.py
new file mode 100644
index 0000000..bee2f73
--- /dev/null
+++ b/driver/usb/test_driver_usb_integration.py
@@ -0,0 +1,405 @@
+############################################################################
+#
+# 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 USB CDC/ACM link survives Linux USB 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 unprompted: with ``power/control=auto`` and the
+usual ``autosuspend_delay_ms=2000``, closing the tty is enough. The test only
+has to force it deterministically, so each cycle re-arms runtime PM and waits
+for ``runtime_status`` to actually reach ``suspended`` before judging anything
+-- a resumed device will not idle out again on its own until PM is toggled.
+
+Opening the port is what resumes the device. Bytes are then counted both over
+the read window and over its trailing second; the tail count is what separates
+a working link from one that only flushed its stale CDC TX buffer on resume.
+
+Host requirements:
+
+- Linux, plus passwordless ``sudo`` for two sysfs power attributes.
+- The port under test is not the NTFC console. The console stays open for the
+ whole session, and an open port pins runtime PM, so the device never
+ suspends and the test measures nothing.
+- The board transmits unprompted on that port: a banner, a telemetry stream,
+ anything periodic.
+
+Wiring comes from the environment. ``NTFC_USB_CDC_DEVICE`` is the host path of
+the port under test, defaulting to the only CDC/ACM port present, and
+``NTFC_USB_CDC_PATH`` is the same port as the board names it.
+"""
+
+import errno
+import glob
+import logging
+import os
+import subprocess
+import sys
+import time
+from dataclasses import dataclass, field
+from typing import Dict, Iterator, List, Optional, Tuple
+
+import pytest
+import serial
+
+pytestmark = pytest.mark.dep_config("CONFIG_CDCACM")
+
+SYSFS_TTY = "/sys/class/tty"
+DEV_SERIAL_BY_ID = "/dev/serial/by-id"
+PROCFS = "/proc"
+
+CDC_DEVICE = os.getenv("NTFC_USB_CDC_DEVICE", "")
+CDC_PATH = os.getenv("NTFC_USB_CDC_PATH", "/dev/ttyACM0")
+
+CYCLES = int(os.getenv("NTFC_USB_SUSPEND_CYCLES", "5"))
+BAUD = 115200
+DELAY_MS = 1000
+READ_SECS = 2.0
+TAIL_SECS = 1.0
+MIN_TAIL_BYTES = 2000
+
+
+class UsbSuspendError(Exception):
+ """Raised when the host cannot be brought into a measurable state."""
+
+
+@dataclass
+class StreamRead:
+ """Bytes seen in a read window and in its trailing part."""
+
+ total: int = 0
+ tail: int = 0
+ error: str = ""
+
+
+@dataclass
+class CycleResult:
+ """Outcome of one suspend/resume cycle."""
+
+ suspended: bool = False
+ stream: StreamRead = field(default_factory=StreamRead)
+ note: str = ""
+
+
+def read_attr(base: str, name: str) -> Optional[str]:
+ """Read one sysfs attribute, 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
+
+
+def usb_device_dir(tty_path: str) -> str:
+ """Map a tty device node to the sysfs directory of its 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)"
+ )
+
+
+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
+
+
+def autodetect() -> str:
+ """Pick the CDC/ACM port when exactly one is present."""
+ found = find_cdc_ports()
+
+ if not found:
+ raise UsbSuspendError("no CDC/ACM port found, set NTFC_USB_CDC_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, set
NTFC_USB_CDC_DEVICE:\n{listing}"
+ )
+
+ return found[0][0]
+
+
+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]
+
+ with open(os.path.join(PROCFS, pid, "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))
+
+
+def require_free_port(dev: str, baud: int) -> None:
+ """Reject a port that another process holds open."""
+ 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'}; an open port "
+ "keeps the device active, so it never suspends"
+ )
+
+
+def read_stream(dev: str, 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.
+ """
+ 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
+
+
+def board_cdc_writable() -> bool:
+ """Ask the board itself whether its CDC port is writable again."""
+ # NSH renders -ENOTCONN as "Transport endpoint is not connected", so a
+ # match means the class driver never saw the resume. Any other outcome,
+ # including a timeout, leaves the host-side byte count as the verdict.
+ ret = pytest.product.sendCommand(
+ f"echo probe > {CDC_PATH}", "not connected", timeout=5
+ )
+
+ return ret != 0
+
+
+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."""
+ self.usbdir = usbdir
+ 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."""
+ target = os.path.join(self.path, name)
+ proc = subprocess.run(
+ ["sudo", "-n", "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.
+ """
+ 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."""
+ deadline = time.time() + timeout
+
+ while time.time() < deadline:
+ if self.get_attr("runtime_status") == "suspended":
+ return True
+
+ time.sleep(0.1)
+
+ return False
+
+
+def run_cycle(device: str, power: UsbPower) -> CycleResult:
+ """Force one suspend, resume by opening the port, and measure."""
+ power.arm(DELAY_MS)
+ suspended = power.wait_suspended(DELAY_MS / 1000.0 + 6.0)
+ stream = read_stream(device, READ_SECS, TAIL_SECS)
+ note = ""
+
+ if 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.
+ power.set_attr("control", "on")
+ note = "board-side open ok" if board_cdc_writable() else "board-side
-ENOTCONN"
+
+ return CycleResult(suspended, stream, note)
+
+
+def log_cycle(index: int, result: CycleResult) -> None:
+ """Log the outcome of one cycle."""
+ if result.stream.error:
+ logging.info(
+ "[%d] suspended=%-5s OPEN FAILED: %s",
+ index,
+ result.suspended,
+ result.stream.error,
+ )
+ return
+
+ logging.info(
+ "[%d] suspended=%-5s read=%-7d tail=%-7d %s",
+ index,
+ result.suspended,
+ result.stream.total,
+ result.stream.tail,
+ result.note,
+ )
+
+
[email protected](scope="module")
+def usb_device() -> Iterator[Tuple[str, UsbPower]]:
+ """Resolve the port under test and restore its runtime PM afterwards."""
+ if sys.platform != "linux":
+ pytest.skip("USB runtime suspend is a Linux host feature")
+
+ if subprocess.run(["sudo", "-n", "true"], check=False).returncode != 0:
+ pytest.skip("needs passwordless sudo to write sysfs power attributes")
+
+ try:
+ device = CDC_DEVICE or autodetect()
+ power = UsbPower(usb_device_dir(device))
+ require_free_port(device, BAUD)
+ except UsbSuspendError as exc:
+ pytest.skip(str(exc))
+
+ logging.info(
+ "device %s -> %s, usb %s %s:%s",
+ device,
+ os.path.realpath(device),
+ os.path.basename(power.usbdir),
+ read_attr(power.usbdir, "idVendor"),
+ read_attr(power.usbdir, "idProduct"),
+ )
+
+ try:
+ yield device, power
+ finally:
+ power.restore()
+
+
+def test_cdcacm_survives_host_suspend(usb_device: Tuple[str, UsbPower]) ->
None:
+ """The CDC/ACM link must still carry data after every host suspend."""
+ device, power = usb_device
+ results = []
+
+ for index in range(1, CYCLES + 1):
+ result = run_cycle(device, power)
+ log_cycle(index, result)
+ results.append(result)
+
+ tested = [res for res in results if res.suspended]
+
+ if not tested:
+ pytest.skip(
+ f"the host never suspended the device; check "
+ f"{power.usbdir}/power/runtime_usage, something holds a "
+ "runtime PM reference"
+ )
+
+ good = [res for res in tested if res.stream.tail >= MIN_TAIL_BYTES]
+
+ assert len(good) == len(tested), (
+ f"the link recovered from {len(good)} of {len(tested)} suspends; a "
+ "cycle that reads bytes but no tail bytes only flushed the stale CDC "
+ "TX buffer on resume"
+ )