diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 1ab54a1..5962c04 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -9,6 +9,15 @@ # dependency is needed. It imports the SDK straight from src/ (see # tests/_sdkpath.py), so both src/ and tests/ must be tracked. # +# The suite runs twice, on the runner's own python3 (3.10) and on 3.14. One +# interpreter is one set of blind spots: 3.14 tightened fcntl.ioctl() and broke +# connect() against every real gripper (#63) while this suite stayed green. +# +# The second interpreter is a step in the same job, not a matrix. `main` requires +# a status check named exactly `test`, and a matrix names its checks +# `test (3.10)` / `test (3.14)`, which would leave that requirement waiting for a +# check that never reports, blocking every merge. +# # AGENTS.md is deliberately not linted: it is canonical content synced from # repo-template and must not be edited here. name: CI @@ -54,3 +63,13 @@ jobs: - name: Run unit tests run: python3 -m unittest discover -s tests -t tests -v + + - name: Setup Python 3.14 + uses: actions/setup-python@v5 + with: + python-version: "3.14" + + - name: Run unit tests on 3.14 + run: | + python3 -VV + python3 -m unittest discover -s tests -t tests -v diff --git a/src/litegrip/can/transport.py b/src/litegrip/can/transport.py index df6b03c..c977477 100644 --- a/src/litegrip/can/transport.py +++ b/src/litegrip/can/transport.py @@ -41,13 +41,22 @@ _SIOCGIFMTU = 0x8921 +# sizeof(struct ifreq) — 40 bytes on 64-bit Linux, 32 on 32-bit. SIOCGIFMTU +# writes back the whole struct, not just ifr_name plus ifr_mtu. +_IFREQ_SIZE = 40 + def _iface_mtu(sock: socket.socket, iface: str) -> Optional[int]: """Read interface MTU (16=classic CAN, 72=CAN FD). Returns None on failure.""" try: ifr = struct.pack("16sH", iface.encode("utf-8")[:15], 0) + # Hand over a whole struct ifreq: the kernel writes all of it back, and + # since 3.14 fcntl.ioctl() raises SystemError (not OSError) when + # anything lands past the end of the argument. The reply is exactly as + # long as the argument, so slice the mtu back out of the front. + ifr = ifr.ljust(_IFREQ_SIZE, b"\x00") res = fcntl.ioctl(sock.fileno(), _SIOCGIFMTU, ifr) - return struct.unpack("16sH", res)[1] + return struct.unpack("16sH", res[:18])[1] except OSError: return None diff --git a/tests/test_iface_mtu.py b/tests/test_iface_mtu.py new file mode 100644 index 0000000..88e9549 --- /dev/null +++ b/tests/test_iface_mtu.py @@ -0,0 +1,98 @@ +"""``_iface_mtu()`` 交给内核的缓冲区必须装得下整个 ``struct ifreq``。 + +``SIOCGIFMTU`` 回写的是一整个 ``struct ifreq``(16 字节名字 + 一个必须装得下 +所有成员的联合体,64 位 Linux 上 40 字节),不是「名字 + mtu」那 18 字节。 +多写出去的部分在 Python 3.14 之前没人看 —— 3.14 起 ``fcntl.ioctl()`` 会在参数 +末尾压一枚哨兵字节,内核写过去就抛 ``SystemError: buffer overflow`` +(cpython c2eaeee3)。于是 18 字节的缓冲区在 3.14 上直接让 +``CanTransport.open()`` 炸掉:真机连不上,而模拟路径根本不走 ``_iface_mtu()``, +所以自检全绿、夹爪失联,报出来的还只是上层那句笼统的「连接失败」。 + +所以这里钉两条:交给 ioctl 的缓冲区够大,以及真 ioctl 仍然读得出网卡的 MTU。 +第一条用假 ioctl 量缓冲区长度,任何解释器上都测得出这个回归;第二条要真内核, +而 3.14 上它抛的是 ``SystemError``(不是 ``OSError``),一路冒到 ``connect()``。 +""" + +from __future__ import annotations + +import errno +import socket +import struct +import sys +import unittest +import unittest.mock + +import _sdkpath # noqa: F401 +from litegrip.can import transport + +_SIOCGIFMTU = 0x8921 + + +class TestIfreqBufferSize(unittest.TestCase): + """交给内核的缓冲区按整个 ifreq 给。""" + + def _sock(self) -> socket.socket: + sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM) + self.addCleanup(sock.close) + return sock + + def test_buffer_holds_a_whole_ifreq(self): + """内核照原样回写整个 ifreq,所以缓冲区得有那么长。""" + seen = {} + + def fake_ioctl(fd, request, arg): + seen["request"] = request + seen["len"] = len(arg) + seen["iface"] = struct.unpack("16s", arg[:16])[0].rstrip(b"\x00") + # 内核把整结构回写:ifr_mtu 是 ifr_ifru 顶上的那个 int。 + return arg[:16] + struct.pack("=i", 72).ljust(len(arg) - 16, b"\x00") + + with unittest.mock.patch.object(transport.fcntl, "ioctl", fake_ioctl): + mtu = transport._iface_mtu(self._sock(), "can0") + + self.assertEqual(seen["request"], _SIOCGIFMTU) + self.assertEqual(seen["iface"], b"can0") + self.assertGreaterEqual( + seen["len"], 40, + "SIOCGIFMTU 回写整个 struct ifreq(64 位 40 字节,32 位 32 字节);" + "缓冲区短了内核就写到尾巴外面去,3.14 上判 buffer overflow") + # 回写比传进去的长不出来,但传进去的长了就得切回前面 18 字节。 + self.assertEqual(mtu, 72) + + def test_returns_none_when_the_ioctl_fails(self): + """读不出来只是没有提示,不该把 connect() 带下去。""" + + def failing_ioctl(fd, request, arg): + raise OSError(errno.ENODEV, "no such device") + + with unittest.mock.patch.object(transport.fcntl, "ioctl", failing_ioctl): + self.assertIsNone(transport._iface_mtu(self._sock(), "can0")) + + +@unittest.skipUnless(sys.platform.startswith("linux"), "SIOCGIFMTU 是 Linux 的东西") +class TestAgainstTheRealKernel(unittest.TestCase): + """真 ioctl 这一条。""" + + def _sysfs_mtu(self, iface: str) -> int: + with open("/sys/class/net/%s/mtu" % iface, "r") as fh: + return int(fh.read()) + + def test_every_interface_reads_back_its_mtu(self): + """网卡挨个读一遍:3.14 上旧写法在这里就炸,而 3.10 看不出来。""" + sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM) + self.addCleanup(sock.close) + + checked = 0 + for _, iface in socket.if_nameindex(): + expected = self._sysfs_mtu(iface) + mtu = transport._iface_mtu(sock, iface) + self.assertIsNotNone(mtu, "%s: ioctl 没读出来" % iface) + # ifr_mtu 是 int,而这里只取低 16 位:环回的 65536 读成 0。 + if expected <= 0xFFFF: + self.assertEqual(mtu, expected, iface) + checked += 1 + self.assertGreater(checked, 0, "if_nameindex() 一个网卡都没报出来") + + +if __name__ == "__main__": + unittest.main()