From ad605f65e95a1d3982a2f8ff4b514293967cb16f Mon Sep 17 00:00:00 2001 From: cao-xiao-hao <127832643+cao-xiao-hao@users.noreply.github.com> Date: Wed, 14 Oct 2026 11:54:30 +0800 Subject: [PATCH] fix: size the SIOCGIFMTU buffer for a whole struct ifreq MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Python 3.14 put a guard byte after the argument fcntl.ioctl() is handed and raises SystemError when the kernel writes past it. SIOCGIFMTU writes back a whole struct ifreq — 40 bytes on 64-bit Linux — so the 18-byte buffer _iface_mtu() passed tripped the guard: connect() died with "buffer overflow" on every real gripper, while the simulated paths never reach _iface_mtu() and stayed green. Pad the buffer to sizeof(struct ifreq), and slice the mtu back out of the front of the reply — the reply is as long as the argument on every version. The suite could not have caught this, because 3.10 and 3.12 accept the short buffer silently, so the unit tests now run on 3.14 as well. Closes #63 --- .github/workflows/ci.yml | 19 +++++++ src/litegrip/can/transport.py | 11 +++- tests/test_iface_mtu.py | 98 +++++++++++++++++++++++++++++++++++ 3 files changed, 127 insertions(+), 1 deletion(-) create mode 100644 tests/test_iface_mtu.py 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()