Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
19 changes: 19 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
11 changes: 10 additions & 1 deletion src/litegrip/can/transport.py
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
98 changes: 98 additions & 0 deletions tests/test_iface_mtu.py
Original file line number Diff line number Diff line change
@@ -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()
Loading