Files
sing-box/service/usbip/linux_test.go
T
世界 9c8147f234 usbip: enforce broadcast invariant and clone-on-stale to close recurring races
- Route every reserved-state mutation through withInventoryWrite; the
  lease insert path now broadcasts so extended subscribers see the busy
  transition immediately instead of waiting for the next mutation. Folds
  cleanupExpiredLocked changes into the broadcast decision on every
  IssueLease early-reject path. Rename the field to inventory and force
  read/write/write-quiet sites through dedicated accessors so future
  callers cannot bypass the invariant.
- Mirror the linux clone-then-swap pattern in darwinExportHost.Reconcile
  via cloneDarwinExport, so the ledger's unlocked Snapshot / LeaseCheck
  reads never observe a half-mutated stale flag. Documented as
  docs/adr/0001-export-pointer-immutability.md and on the Export
  interface; both hosts now share the applyStaleClones helper.
- Rewrite 27 if (_, )?err := …; err != nil sites to assign-then-check
  per .claude/rules/go-syntax.md.
- Delete parse / builder / tautological tests forbidden by
  .claude/rules/code-test.md (option/usbip_test.go,
  iso_scheduler_test.go, usbhost_darwin_status_test.go).
- Tag nine more usbip files with linux || (darwin && cgo); fixes
  pre-existing windows / android lint failures because the protocol
  types were only consumed by tagged code.
2026-06-09 10:42:32 +08:00

439 lines
12 KiB
Go

//go:build linux
package usbip
import (
"bytes"
"context"
"fmt"
"io"
"net"
"os"
"os/exec"
"path/filepath"
"sync"
"testing"
"time"
"github.com/sagernet/sing-box/log"
"github.com/sagernet/sing-box/option"
"github.com/stretchr/testify/require"
"golang.org/x/sys/unix"
)
type testLogWriter struct {
access sync.Mutex
buffer bytes.Buffer
}
func (w *testLogWriter) Write(p []byte) (int, error) {
w.access.Lock()
defer w.access.Unlock()
return w.buffer.Write(p)
}
func (w *testLogWriter) String() string {
w.access.Lock()
defer w.access.Unlock()
return w.buffer.String()
}
type opaqueConn struct {
net.Conn
}
func newTestLogger(t testing.TB) log.ContextLogger {
t.Helper()
writer := new(testLogWriter)
factory := log.NewDefaultFactory(
context.Background(),
log.Formatter{
BaseTime: time.Now(),
DisableColors: true,
},
writer,
"",
nil,
false,
)
factory.SetLevel(log.LevelTrace)
t.Cleanup(func() {
if output := writer.String(); t.Failed() && output != "" {
t.Logf("USB/IP log:\n%s", output)
}
_ = factory.Close()
})
return factory.NewLogger("usbip")
}
func duplicateConnFromFD(t *testing.T, fd uintptr, name string) net.Conn {
t.Helper()
dupFD, err := unix.Dup(int(fd))
require.NoError(t, err)
file := os.NewFile(uintptr(dupFD), name)
conn, err := net.FileConn(file)
closeErr := file.Close()
require.NoError(t, err)
require.NoError(t, closeErr)
return conn
}
func duplicateHandoffKernelConn(t *testing.T, handoff *kernelHandoffSession) net.Conn {
t.Helper()
conn := duplicateConnFromFD(t, handoff.file.Fd(), "usbip-test-kernel")
require.NoError(t, handoff.closeKernelFD())
return conn
}
func requireConnRead(t *testing.T, conn net.Conn, expected []byte) {
t.Helper()
buffer := make([]byte, len(expected))
_, err := io.ReadFull(conn, buffer)
require.NoError(t, err)
require.Equal(t, expected, buffer)
}
func setConnDeadline(t *testing.T, conn net.Conn) {
t.Helper()
require.NoError(t, conn.SetDeadline(time.Now().Add(3*time.Second)))
}
func requireStreamSocketFD(t *testing.T, fd uintptr) {
t.Helper()
socketType, err := unix.GetsockoptInt(int(fd), unix.SOL_SOCKET, unix.SO_TYPE)
require.NoError(t, err)
require.Equal(t, unix.SOCK_STREAM, socketType)
}
type testUSBGadget struct {
path string
serial string
busid string
}
func requireRoot(t *testing.T) {
t.Helper()
if os.Geteuid() != 0 {
t.Skip("root required")
}
}
func requireKernelModule(t *testing.T, module string) {
t.Helper()
_, statErr := os.Stat(filepath.Join("/sys/module", module))
if statErr == nil {
return
}
modprobePath, err := findModprobePath()
if err != nil {
t.Skipf("kernel module %s unavailable: %v", module, err)
}
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
command := exec.CommandContext(ctx, modprobePath, module)
command.Env = os.Environ()
output, err := command.CombinedOutput()
if ctx.Err() != nil {
t.Skipf("modprobe %s timed out: %s", module, string(output))
}
if err != nil {
t.Skipf("kernel module %s unavailable: %v: %s", module, err, string(output))
}
}
func requireUSBIPHost(t *testing.T) {
t.Helper()
err := ensureKernelPath(sysUsbipHostDriver, "usbip-host", "usbip-host driver")
if err != nil {
t.Skipf("usbip-host unavailable: %v", err)
}
}
func requireVHCI(t *testing.T) {
t.Helper()
err := ensureKernelPath(sysVHCIControllerV0, "vhci-hcd", "vhci_hcd.0")
if err != nil {
t.Skipf("vhci_hcd unavailable: %v", err)
}
}
func writeSysfsLine(path string, content string) error {
return os.WriteFile(path, []byte(content+"\n"), 0)
}
func newTestUSBGadget(t *testing.T) *testUSBGadget {
t.Helper()
requireRoot(t)
requireKernelModule(t, "configfs")
requireKernelModule(t, "libcomposite")
requireKernelModule(t, "dummy_hcd")
udcs, err := os.ReadDir("/sys/class/udc")
if err != nil {
t.Skipf("USB device controllers unavailable: %v", err)
}
if len(udcs) == 0 {
t.Skip("USB device controllers unavailable")
}
gadget := &testUSBGadget{
path: filepath.Join("/sys/kernel/config/usb_gadget", fmt.Sprintf("codex_usbip_%d", time.Now().UnixNano())),
serial: fmt.Sprintf("codex-usbip-%d", time.Now().UnixNano()),
}
require.NoError(t, os.MkdirAll(filepath.Join(gadget.path, "strings/0x409"), 0o755))
require.NoError(t, os.MkdirAll(filepath.Join(gadget.path, "configs/c.1/strings/0x409"), 0o755))
require.NoError(t, os.Mkdir(filepath.Join(gadget.path, "functions/acm.usb0"), 0o755))
require.NoError(t, writeSysfs(filepath.Join(gadget.path, "idVendor"), "0x1d6b"))
require.NoError(t, writeSysfs(filepath.Join(gadget.path, "idProduct"), "0x0104"))
require.NoError(t, writeSysfs(filepath.Join(gadget.path, "strings/0x409/serialnumber"), gadget.serial))
require.NoError(t, writeSysfs(filepath.Join(gadget.path, "strings/0x409/manufacturer"), "OpenAI"))
require.NoError(t, writeSysfs(filepath.Join(gadget.path, "strings/0x409/product"), "Codex USBIP Test"))
require.NoError(t, writeSysfs(filepath.Join(gadget.path, "configs/c.1/strings/0x409/configuration"), "config-1"))
require.NoError(t, os.Symlink(filepath.Join(gadget.path, "functions/acm.usb0"), filepath.Join(gadget.path, "configs/c.1/acm.usb0")))
require.NoError(t, writeSysfs(filepath.Join(gadget.path, "UDC"), udcs[0].Name()))
require.Eventually(t, func() bool {
devices, err := listUSBDevices()
if err != nil {
return false
}
for i := range devices {
if devices[i].VendorID == 0x1d6b &&
devices[i].ProductID == 0x0104 &&
devices[i].Serial == gadget.serial {
gadget.busid = devices[i].BusID
return true
}
}
return false
}, 5*time.Second, 100*time.Millisecond)
t.Cleanup(func() {
if gadget.busid != "" {
driver, err := currentDriver(gadget.busid)
if err == nil {
switch driver {
case "usbip-host":
_ = writeSysfs(filepath.Join(sysUsbipHostDriver, "unbind"), gadget.busid)
_ = writeSysfs(filepath.Join(sysUsbipHostDriver, "match_busid"), "del "+gadget.busid)
_ = writeSysfs("/sys/bus/usb/drivers/usb/bind", gadget.busid)
case "usb":
case "":
default:
_ = writeSysfs("/sys/bus/usb/drivers/usb/bind", gadget.busid)
}
}
}
_ = writeSysfsLine(filepath.Join(gadget.path, "UDC"), "")
_ = os.Remove(filepath.Join(gadget.path, "configs/c.1/acm.usb0"))
_ = os.Remove(filepath.Join(gadget.path, "functions/acm.usb0"))
_ = os.Remove(filepath.Join(gadget.path, "configs/c.1/strings/0x409"))
_ = os.Remove(filepath.Join(gadget.path, "configs/c.1"))
_ = os.Remove(filepath.Join(gadget.path, "strings/0x409"))
_ = os.Remove(gadget.path)
})
return gadget
}
func setLinuxExport(host *linuxExportHost, exp *linuxExport) {
host.access.Lock()
defer host.access.Unlock()
host.exports[exp.busid] = exp
}
func deleteLinuxExport(host *linuxExportHost, busid string) {
host.access.Lock()
defer host.access.Unlock()
delete(host.exports, busid)
}
func TestUSBIPConnHandoffDirectTCP(t *testing.T) {
t.Parallel()
listener, err := net.Listen("tcp", "127.0.0.1:0")
require.NoError(t, err)
defer listener.Close()
accepted := make(chan net.Conn, 1)
go func() {
conn, _ := listener.Accept()
accepted <- conn
}()
conn, err := net.Dial("tcp", listener.Addr().String())
require.NoError(t, err)
defer conn.Close()
acceptedConn := <-accepted
defer acceptedConn.Close()
handoff, err := newKernelHandoffSession(context.Background(), conn, newTestLogger(t), "test", "direct")
require.NoError(t, err)
defer handoff.Close()
require.Nil(t, handoff.relayConn)
requireStreamSocketFD(t, handoff.file.Fd())
require.NoError(t, handoff.Start())
_, err = conn.Write([]byte("closed"))
require.Error(t, err)
require.NoError(t, acceptedConn.Close())
select {
case <-handoff.Done():
case <-time.After(time.Second):
t.Fatal("timed out waiting for direct handoff monitor")
}
}
func TestUSBIPConnHandoffCloseBeforeStartIsSafe(t *testing.T) {
t.Parallel()
listener, err := net.Listen("tcp", "127.0.0.1:0")
require.NoError(t, err)
defer listener.Close()
accepted := make(chan net.Conn, 1)
go func() {
conn, _ := listener.Accept()
accepted <- conn
}()
conn, err := net.Dial("tcp", listener.Addr().String())
require.NoError(t, err)
acceptedConn := <-accepted
defer acceptedConn.Close()
handoff, err := newKernelHandoffSession(context.Background(), conn, newTestLogger(t), "test", "close-before-start")
require.NoError(t, err)
require.NoError(t, handoff.Close())
require.NoError(t, handoff.Start())
select {
case <-handoff.Done():
default:
t.Fatal("expected close-before-start handoff to be done")
}
setConnDeadline(t, acceptedConn)
buffer := make([]byte, 1)
_, err = acceptedConn.Read(buffer)
require.ErrorIs(t, err, io.EOF)
}
func TestUSBIPConnHandoffRelaySocketpairCopies(t *testing.T) {
t.Parallel()
left, right := net.Pipe()
defer right.Close()
handoff, err := newKernelHandoffSession(t.Context(), opaqueConn{Conn: left}, newTestLogger(t), "test", "relay")
require.NoError(t, err)
defer handoff.Close()
require.NotNil(t, handoff.relayConn)
requireStreamSocketFD(t, handoff.file.Fd())
kernelConn := duplicateHandoffKernelConn(t, handoff)
defer kernelConn.Close()
setConnDeadline(t, right)
setConnDeadline(t, kernelConn)
require.NoError(t, handoff.Start())
_, err = right.Write([]byte("ping"))
require.NoError(t, err)
requireConnRead(t, kernelConn, []byte("ping"))
_, err = kernelConn.Write([]byte("pong"))
require.NoError(t, err)
requireConnRead(t, right, []byte("pong"))
require.NoError(t, right.Close())
require.NoError(t, kernelConn.Close())
select {
case <-handoff.Done():
case <-time.After(time.Second):
t.Fatal("timed out waiting for relay handoff")
}
}
func TestUSBIPLinuxSmoke(t *testing.T) {
requireRoot(t)
requireUSBIPHost(t)
requireVHCI(t)
gadget := newTestUSBGadget(t)
device, err := readSysfsDevice(gadget.busid, filepath.Join(sysBusUSBDevices, gadget.busid))
require.NoError(t, err)
require.Equal(t, gadget.busid, device.BusID)
host := newLinuxExportHost(newTestLogger(t), nil)
exp, err := host.bindOne(&device)
require.NoError(t, err)
setLinuxExport(host, exp)
host.access.Lock()
_, present := host.exports[gadget.busid]
host.access.Unlock()
require.True(t, present)
driver, err := currentDriver(gadget.busid)
require.NoError(t, err)
require.Equal(t, "usbip-host", driver)
status, err := readUsbipStatus(gadget.busid)
require.NoError(t, err)
require.Equal(t, usbipStatusAvailable, status)
require.NoError(t, writeSysfs(filepath.Join(sysUsbipHostDriver, "unbind"), gadget.busid))
require.NoError(t, writeSysfs(filepath.Join(sysUsbipHostDriver, "match_busid"), "del "+gadget.busid))
require.NoError(t, writeSysfs("/sys/bus/usb/drivers/usb/bind", gadget.busid))
deleteLinuxExport(host, gadget.busid)
driver, err = currentDriver(gadget.busid)
require.NoError(t, err)
require.Equal(t, "usb", driver)
}
func TestUSBIPLinuxReconcileReleaseRestoresOriginalDriver(t *testing.T) {
requireRoot(t)
requireUSBIPHost(t)
requireVHCI(t)
gadget := newTestUSBGadget(t)
host := newLinuxExportHost(newTestLogger(t), []option.USBIPDeviceMatch{{BusID: gadget.busid}})
require.NoError(t, host.Start(context.Background()))
snapshot, released, err := host.Reconcile(context.Background(), func(string) bool { return false })
require.NoError(t, err)
require.Empty(t, released)
_, exported := snapshot[gadget.busid]
require.True(t, exported)
driver, err := currentDriver(gadget.busid)
require.NoError(t, err)
require.Equal(t, "usbip-host", driver)
host.matches = nil
snapshot, released, err = host.Reconcile(context.Background(), func(string) bool { return false })
require.NoError(t, err)
require.Equal(t, []string{gadget.busid}, released)
require.Empty(t, snapshot)
driver, err = currentDriver(gadget.busid)
require.NoError(t, err)
require.Equal(t, "usb", driver)
}