From 249ba662c497735af06b95bbfcbfcef05185dea4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E4=B8=96=E7=95=8C?= Date: Fri, 15 May 2026 18:13:36 +0800 Subject: [PATCH] usbip: fix lease identity, linux replacement, and darwin stalls --- service/usbip/control_protocol.go | 1 + service/usbip/export_ledger.go | 104 ++++++--- service/usbip/export_ledger_test.go | 104 +++++++++ service/usbip/host.go | 3 + service/usbip/host_darwin.go | 4 + service/usbip/host_linux.go | 242 +++++++++++++++++--- service/usbip/server.go | 2 +- service/usbip/usbhost_darwin.go | 4 + service/usbip/usbhost_darwin.h | 2 + service/usbip/usbhost_darwin.m | 124 +++++++--- service/usbip/usbhost_darwin_status_test.go | 32 +++ 11 files changed, 534 insertions(+), 88 deletions(-) create mode 100644 service/usbip/usbhost_darwin_status_test.go diff --git a/service/usbip/control_protocol.go b/service/usbip/control_protocol.go index c72e14ea9..bd0e9559b 100644 --- a/service/usbip/control_protocol.go +++ b/service/usbip/control_protocol.go @@ -127,6 +127,7 @@ type serverImportLease struct { BusID string ClientNonce uint64 Generation uint64 + Identity ExportLeaseIdentity Expires time.Time } diff --git a/service/usbip/export_ledger.go b/service/usbip/export_ledger.go index dcc375aef..a451097fc 100644 --- a/service/usbip/export_ledger.go +++ b/service/usbip/export_ledger.go @@ -157,6 +157,7 @@ func (l *exportLedger) TryReserveForImport(ctx context.Context, busid string) (E if !found { return nil, false, "unknown busid" } + identity := export.LeaseIdentity() if busy || leased { return nil, false, deviceStateBusy } @@ -167,7 +168,7 @@ func (l *exportLedger) TryReserveForImport(ctx context.Context, busid string) (E l.slow.Lock() defer l.slow.Unlock() current, stillExported := l.exports[busid] - if !stillExported || current != export { + if !stillExported || current.LeaseIdentity() != identity { return nil, false, "unknown busid" } if l.busy[busid] { @@ -190,9 +191,9 @@ func (l *exportLedger) ReleaseImport(ctx context.Context, busid string, removeEx l.BroadcastIfChanged(ctx) } -// IssueLease captures the seq generation at entry so a subsequent -// ConsumeLeaseAndReserve can reject stale leases issued before a -// topology change. +// IssueLease captures the current broadcast sequence as opaque metadata +// for control clients. Lease correctness itself is pinned to the +// export's internal identity, not to that sequence number. func (l *exportLedger) IssueLease(ctx context.Context, subID uint64, request controlLeaseRequest) controlLeaseResponse { response := controlLeaseResponse{ BusID: request.BusID, @@ -232,6 +233,7 @@ func (l *exportLedger) IssueLease(ctx context.Context, subID uint64, request con } l.slow.Unlock() + identity := export.LeaseIdentity() leaseOK, leaseReason := export.LeaseCheck(ctx) if !leaseOK { response.ErrorCode = leaseErrorUnavailable @@ -244,7 +246,7 @@ func (l *exportLedger) IssueLease(ctx context.Context, subID uint64, request con now = l.now() l.cleanupExpiredLocked(now) current, stillExported := l.exports[request.BusID] - if !stillExported || current != export { + if !stillExported || current.LeaseIdentity() != identity { response.ErrorCode = leaseErrorUnavailable response.ErrorMessage = "unknown busid" return response @@ -266,6 +268,7 @@ func (l *exportLedger) IssueLease(ctx context.Context, subID uint64, request con BusID: request.BusID, ClientNonce: request.ClientNonce, Generation: generation, + Identity: current.LeaseIdentity(), Expires: now.Add(l.ttl), } l.leases[request.BusID] = lease @@ -275,60 +278,101 @@ func (l *exportLedger) IssueLease(ctx context.Context, subID uint64, request con return response } -// ConsumeLeaseAndReserve atomically validates a lease, removes its entry, -// and marks the busid busy under a single slow-lock critical section so -// no concurrent import can observe the consume-before-reserve gap, and -// no concurrent broadcast can observe a transient busy=true that we -// later roll back. The generation snapshot is taken before the slow -// critical section so the comparison either matches the lease exactly -// or is strictly stale (the safe rejection direction): a broadcast that -// fires between the snapshot and slow.Lock can only advance seq. +// ConsumeLeaseAndReserve validates the requested lease against the +// current export identity, reruns LeaseCheck outside the slow lock, then +// atomically consumes the lease and marks the busid busy. Correctness is +// tied to the export identity rather than to the control sequence. // // Consume-on-read semantics from the old ConsumeLease are preserved: // the lease entry is removed on every outcome except a nonce/ID // mismatch (which preserves the lease for the legitimate holder). -// LeaseCheck is not re-run — the lease attests that it passed at issue -// time, and the generation equality test confirms the export has not -// been reconciled since. The caller must pair every success with a -// later ReleaseImport. -func (l *exportLedger) ConsumeLeaseAndReserve(request ImportExtRequest) (Export, bool, string) { - l.fast.Lock() - currentGeneration := l.seq - l.fast.Unlock() - +// The caller must pair every success with a later ReleaseImport. +func (l *exportLedger) ConsumeLeaseAndReserve(ctx context.Context, request ImportExtRequest) (Export, bool, string) { + var ( + export Export + identity ExportLeaseIdentity + ) l.slow.Lock() - defer l.slow.Unlock() - now := l.now() l.cleanupExpiredLocked(now) lease, found := l.leases[request.BusID] + if !found { + l.slow.Unlock() + return nil, false, "lease not found" + } + if lease.ID != request.LeaseID || lease.ClientNonce != request.ClientNonce { + l.slow.Unlock() + return nil, false, "lease mismatch" + } + if !now.Before(lease.Expires) { + delete(l.leases, request.BusID) + l.slow.Unlock() + return nil, false, "lease expired" + } + export, stillExported := l.exports[request.BusID] + if !stillExported { + delete(l.leases, request.BusID) + l.slow.Unlock() + return nil, false, "unknown busid" + } + identity = export.LeaseIdentity() + if identity != lease.Identity { + delete(l.leases, request.BusID) + l.slow.Unlock() + return nil, false, "lease stale" + } + if l.busy[request.BusID] { + delete(l.leases, request.BusID) + l.slow.Unlock() + return nil, false, deviceStateBusy + } + l.slow.Unlock() + + leaseOK, leaseReason := export.LeaseCheck(ctx) + if !leaseOK { + l.slow.Lock() + currentLease, exists := l.leases[request.BusID] + if exists && currentLease.ID == request.LeaseID && currentLease.ClientNonce == request.ClientNonce { + delete(l.leases, request.BusID) + } + l.slow.Unlock() + return nil, false, leaseReason + } + + l.slow.Lock() + defer l.slow.Unlock() + + now = l.now() + l.cleanupExpiredLocked(now) + + lease, found = l.leases[request.BusID] if !found { return nil, false, "lease not found" } if lease.ID != request.LeaseID || lease.ClientNonce != request.ClientNonce { return nil, false, "lease mismatch" } - if lease.Generation != currentGeneration { - delete(l.leases, request.BusID) - return nil, false, "lease stale" - } if !now.Before(lease.Expires) { delete(l.leases, request.BusID) return nil, false, "lease expired" } - export, stillExported := l.exports[request.BusID] + current, stillExported := l.exports[request.BusID] if !stillExported { delete(l.leases, request.BusID) return nil, false, "unknown busid" } + if lease.Identity != identity || current.LeaseIdentity() != identity { + delete(l.leases, request.BusID) + return nil, false, "lease stale" + } if l.busy[request.BusID] { delete(l.leases, request.BusID) return nil, false, deviceStateBusy } delete(l.leases, request.BusID) l.busy[request.BusID] = true - return export, true, "" + return current, true, "" } func (l *exportLedger) cleanupExpiredLocked(now time.Time) { diff --git a/service/usbip/export_ledger_test.go b/service/usbip/export_ledger_test.go index bd327a35d..c114a1efd 100644 --- a/service/usbip/export_ledger_test.go +++ b/service/usbip/export_ledger_test.go @@ -53,11 +53,105 @@ func TestSubscribeRetriesSnapshotWhenSequenceAdvances(t *testing.T) { } } +func TestConsumeLeaseAndReserveRejectsIdentityReplacement(t *testing.T) { + ctx := context.Background() + ledger := newExportLedger(nil, time.Second, func() time.Time { return time.Unix(0, 0) }) + original := &testExport{busid: "1-1", vendorID: 0x1111, productID: 0x0001, identity: "linux:original"} + replacement := &testExport{busid: "1-1", vendorID: 0x1111, productID: 0x0001, identity: "linux:replacement"} + + ledger.ApplyHostSnapshot(map[string]Export{original.busid: original}, nil) + lease := ledger.IssueLease(ctx, 1, controlLeaseRequest{BusID: original.busid, ClientNonce: 7}) + ledger.ApplyHostSnapshot(map[string]Export{replacement.busid: replacement}, nil) + + _, ok, reason := ledger.ConsumeLeaseAndReserve(ctx, ImportExtRequest{ + BusID: original.busid, + LeaseID: lease.LeaseID, + ClientNonce: lease.ClientNonce, + }) + if ok { + t.Fatal("expected replaced export lease to be rejected") + } + if reason != "lease stale" { + t.Fatalf("expected lease stale, got %q", reason) + } +} + +func TestConsumeLeaseAndReserveRejectsUnavailableExport(t *testing.T) { + ctx := context.Background() + ledger := newExportLedger(nil, time.Second, func() time.Time { return time.Unix(0, 0) }) + available := true + exp := &testExport{ + busid: "1-1", + vendorID: 0x1111, + productID: 0x0001, + leaseCheck: func(context.Context) (bool, string) { + if available { + return true, "" + } + return false, "capture released" + }, + } + + ledger.ApplyHostSnapshot(map[string]Export{exp.busid: exp}, nil) + lease := ledger.IssueLease(ctx, 1, controlLeaseRequest{BusID: exp.busid, ClientNonce: 9}) + available = false + + _, ok, reason := ledger.ConsumeLeaseAndReserve(ctx, ImportExtRequest{ + BusID: exp.busid, + LeaseID: lease.LeaseID, + ClientNonce: lease.ClientNonce, + }) + if ok { + t.Fatal("expected unavailable export lease to be rejected") + } + if reason != "capture released" { + t.Fatalf("expected capture released, got %q", reason) + } + + _, ok, reason = ledger.ConsumeLeaseAndReserve(ctx, ImportExtRequest{ + BusID: exp.busid, + LeaseID: lease.LeaseID, + ClientNonce: lease.ClientNonce, + }) + if ok { + t.Fatal("expected consumed lease to stay unavailable on retry") + } + if reason != "lease not found" { + t.Fatalf("expected consumed lease to disappear, got %q", reason) + } +} + +func TestConsumeLeaseAndReserveMarksBusyOnSuccess(t *testing.T) { + ctx := context.Background() + ledger := newExportLedger(nil, time.Second, func() time.Time { return time.Unix(0, 0) }) + exp := &testExport{busid: "1-1", vendorID: 0x1111, productID: 0x0001} + + ledger.ApplyHostSnapshot(map[string]Export{exp.busid: exp}, nil) + lease := ledger.IssueLease(ctx, 1, controlLeaseRequest{BusID: exp.busid, ClientNonce: 11}) + + reserved, ok, reason := ledger.ConsumeLeaseAndReserve(ctx, ImportExtRequest{ + BusID: exp.busid, + LeaseID: lease.LeaseID, + ClientNonce: lease.ClientNonce, + }) + if !ok { + t.Fatalf("expected lease reservation success, got %q", reason) + } + if reserved != exp { + t.Fatal("expected to reserve the original export instance") + } + if !ledger.IsBusy(exp.busid) { + t.Fatal("expected successful lease reservation to mark busid busy") + } +} + type testExport struct { busid string vendorID uint16 productID uint16 + identity ExportLeaseIdentity + leaseCheck func(context.Context) (bool, string) onSnapshot func() } @@ -65,6 +159,13 @@ func (e *testExport) BusID() string { return e.busid } +func (e *testExport) LeaseIdentity() ExportLeaseIdentity { + if e.identity != "" { + return e.identity + } + return ExportLeaseIdentity(e.busid) +} + func (e *testExport) Snapshot(ctx context.Context, busy bool) ExportSnapshot { onSnapshot := e.onSnapshot e.onSnapshot = nil @@ -80,6 +181,9 @@ func (e *testExport) Snapshot(ctx context.Context, busy bool) ExportSnapshot { } func (e *testExport) LeaseCheck(ctx context.Context) (bool, string) { + if e.leaseCheck != nil { + return e.leaseCheck(ctx) + } return true, "" } diff --git a/service/usbip/host.go b/service/usbip/host.go index ddd0442f9..3afaf5ac4 100644 --- a/service/usbip/host.go +++ b/service/usbip/host.go @@ -28,9 +28,12 @@ type ImportHost interface { Attach(ctx context.Context, info DeviceInfoTruncated, conn net.Conn) (AttachedSession, error) } +type ExportLeaseIdentity string + type Export interface { BusID() string Snapshot(ctx context.Context, busy bool) ExportSnapshot + LeaseIdentity() ExportLeaseIdentity LeaseCheck(ctx context.Context) (ok bool, reason string) DeviceInfo(ctx context.Context) (DeviceInfoTruncated, error) NewServerDataSession(ctx context.Context, conn net.Conn) (DataSession, error) diff --git a/service/usbip/host_darwin.go b/service/usbip/host_darwin.go index f19ffe439..67e15aea0 100644 --- a/service/usbip/host_darwin.go +++ b/service/usbip/host_darwin.go @@ -282,6 +282,10 @@ func (e *darwinExport) BusID() string { return e.busid } +func (e *darwinExport) LeaseIdentity() ExportLeaseIdentity { + return ExportLeaseIdentity(fmt.Sprintf("darwin:%016x", e.registryID)) +} + func (e *darwinExport) Snapshot(ctx context.Context, busy bool) ExportSnapshot { stableID := fmt.Sprintf("darwin-registry:%016x", e.registryID) if e.stale { diff --git a/service/usbip/host_linux.go b/service/usbip/host_linux.go index 1968d5a3a..0e0f05949 100644 --- a/service/usbip/host_linux.go +++ b/service/usbip/host_linux.go @@ -10,6 +10,7 @@ import ( "net" "os" "path/filepath" + "slices" "strconv" "strings" "sync" @@ -50,6 +51,90 @@ func linuxUSBIPStatusReason(status int) string { } } +type linuxExportIdentity struct { + BusNum uint32 + DevNum uint32 + Speed uint32 + VendorID uint16 + ProductID uint16 + BCDDevice uint16 + DeviceClass uint8 + DeviceSubClass uint8 + DeviceProtocol uint8 + ConfigValue uint8 + NumConfigs uint8 + NumInterfaces uint8 + Serial string + Interfaces []DeviceInterface +} + +func newLinuxExportIdentity(descriptor sysfsDevice) linuxExportIdentity { + return linuxExportIdentity{ + BusNum: descriptor.BusNum, + DevNum: descriptor.DevNum, + Speed: descriptor.Speed, + VendorID: descriptor.VendorID, + ProductID: descriptor.ProductID, + BCDDevice: descriptor.BCDDevice, + DeviceClass: descriptor.DeviceClass, + DeviceSubClass: descriptor.DeviceSubClass, + DeviceProtocol: descriptor.DeviceProtocol, + ConfigValue: descriptor.ConfigValue, + NumConfigs: descriptor.NumConfigs, + NumInterfaces: descriptor.NumInterfaces, + Serial: descriptor.Serial, + Interfaces: slices.Clone(descriptor.Interfaces), + } +} + +func (i linuxExportIdentity) Equal(other linuxExportIdentity) bool { + if i.BusNum != other.BusNum || + i.DevNum != other.DevNum || + i.Speed != other.Speed || + i.VendorID != other.VendorID || + i.ProductID != other.ProductID || + i.BCDDevice != other.BCDDevice || + i.DeviceClass != other.DeviceClass || + i.DeviceSubClass != other.DeviceSubClass || + i.DeviceProtocol != other.DeviceProtocol || + i.ConfigValue != other.ConfigValue || + i.NumConfigs != other.NumConfigs || + i.NumInterfaces != other.NumInterfaces || + i.Serial != other.Serial || + len(i.Interfaces) != len(other.Interfaces) { + return false + } + for index := range i.Interfaces { + if i.Interfaces[index] != other.Interfaces[index] { + return false + } + } + return true +} + +func (i linuxExportIdentity) LeaseIdentity() ExportLeaseIdentity { + var builder strings.Builder + fmt.Fprintf(&builder, "linux:%d:%d:%d:%04x:%04x:%04x:%02x:%02x:%02x:%02x:%02x:%02x:%s", + i.BusNum, + i.DevNum, + i.Speed, + i.VendorID, + i.ProductID, + i.BCDDevice, + i.DeviceClass, + i.DeviceSubClass, + i.DeviceProtocol, + i.ConfigValue, + i.NumConfigs, + i.NumInterfaces, + i.Serial, + ) + for _, iface := range i.Interfaces { + fmt.Fprintf(&builder, "|%02x.%02x.%02x", iface.BInterfaceClass, iface.BInterfaceSubClass, iface.BInterfaceProtocol) + } + return ExportLeaseIdentity(builder.String()) +} + type linuxExportHost struct { logger log.ContextLogger matches []option.USBIPDeviceMatch @@ -58,6 +143,13 @@ type linuxExportHost struct { exports map[string]*linuxExport } +type linuxReconcilePlan struct { + toRelease []*linuxExport + toStale []string + toBind map[string]sysfsDevice + released []string +} + func newLinuxExportHost(logger log.ContextLogger, matches []option.USBIPDeviceMatch) *linuxExportHost { return &linuxExportHost{ logger: logger, @@ -76,9 +168,7 @@ func (h *linuxExportHost) Close() error { h.exports = make(map[string]*linuxExport) h.access.Unlock() for _, exp := range exports { - _, statErr := os.Stat(filepath.Join(sysBusUSBDevices, exp.busid)) - restore := statErr == nil - releaseErr := h.releaseExport(exp, restore) + releaseErr := h.releaseExport(exp, exp.shouldRestoreCurrentDevice()) if releaseErr != nil { h.logger.Warn("rollback ", exp.busid, ": ", releaseErr) } @@ -155,16 +245,48 @@ const ( ueventListenerBackoffMax = 30 * time.Second ) +func classifyLinuxReconcile(current map[string]*linuxExport, desired map[string]sysfsDevice, isBusy func(busid string) bool) linuxReconcilePlan { + remainingDesired := maps.Clone(desired) + plan := linuxReconcilePlan{ + toBind: make(map[string]sysfsDevice), + } + for busid, exp := range current { + device, wanted := remainingDesired[busid] + busy := isBusy(busid) + identityMatches := wanted && exp.identity.Equal(newLinuxExportIdentity(device)) + switch { + case exp.stale: + if busy { + delete(remainingDesired, busid) + continue + } + plan.toRelease = append(plan.toRelease, exp) + plan.released = append(plan.released, busid) + case identityMatches: + delete(remainingDesired, busid) + case busy: + plan.toStale = append(plan.toStale, busid) + delete(remainingDesired, busid) + default: + plan.toRelease = append(plan.toRelease, exp) + plan.released = append(plan.released, busid) + } + } + for busid, device := range remainingDesired { + if isBusy(busid) { + continue + } + plan.toBind[busid] = device + } + return plan +} + func (h *linuxExportHost) Reconcile(ctx context.Context, isBusy func(busid string) bool) (map[string]Export, []string, error) { devices, err := listUSBDevices() if err != nil { return h.snapshotSelf(), nil, E.Cause(err, "enumerate usb devices") } desired := make(map[string]sysfsDevice) - present := make(map[string]struct{}, len(devices)) - for i := range devices { - present[devices[i].BusID] = struct{}{} - } for _, m := range h.matches { for i := range devices { deviceKey := DeviceKey{ @@ -201,36 +323,42 @@ func (h *linuxExportHost) Reconcile(ctx context.Context, isBusy func(busid strin maps.Copy(current, h.exports) h.access.Unlock() - for busid, device := range desired { - if _, ok := current[busid]; ok { - continue + plan := classifyLinuxReconcile(current, desired, isBusy) + toAdd := make([]*linuxExport, 0, len(plan.toBind)) + for _, exp := range plan.toRelease { + err := h.releaseExport(exp, false) + if err != nil { + h.logger.Warn("release ", exp.busid, ": ", err) } + } + + for busid, device := range plan.toBind { exp, bindErr := h.bindOne(&device) if bindErr != nil { return h.snapshotSelf(), nil, E.Cause(bindErr, "bind ", busid) } - h.access.Lock() - h.exports[busid] = exp - h.access.Unlock() + toAdd = append(toAdd, exp) } - var released []string - for busid, exp := range current { - if _, ok := desired[busid]; ok { - continue + h.access.Lock() + for _, busid := range plan.toStale { + exp, ok := h.exports[busid] + if ok { + exp.stale = true } - _, restore := present[busid] - err := h.releaseExport(exp, restore) - if err != nil { - h.logger.Warn("release ", busid, ": ", err) - } - h.access.Lock() - delete(h.exports, busid) - h.access.Unlock() - released = append(released, busid) } + for _, exp := range plan.toRelease { + currentExport, ok := h.exports[exp.busid] + if ok && currentExport == exp { + delete(h.exports, exp.busid) + } + } + for _, exp := range toAdd { + h.exports[exp.busid] = exp + } + h.access.Unlock() - return h.snapshotSelf(), released, nil + return h.snapshotSelf(), plan.released, nil } func (h *linuxExportHost) FinishImport(ctx context.Context, busid string) (bool, error) { @@ -239,7 +367,23 @@ func (h *linuxExportHost) FinishImport(ctx context.Context, busid string) (bool, h.logger.Debug("release ", busid, " from usbip-host: ", err) } waitForUsbipStatusCleared(ctx, busid) - return false, nil + h.access.Lock() + exp, ok := h.exports[busid] + h.access.Unlock() + if !ok || !exp.stale { + return false, nil + } + releaseErr := h.releaseExport(exp, false) + h.access.Lock() + current, stillPresent := h.exports[busid] + if stillPresent && current == exp { + delete(h.exports, busid) + } + h.access.Unlock() + if releaseErr != nil { + h.logger.Warn("release stale ", busid, ": ", releaseErr) + } + return true, E.Errors(err, releaseErr) } func (h *linuxExportHost) snapshotSelf() map[string]Export { @@ -247,6 +391,9 @@ func (h *linuxExportHost) snapshotSelf() map[string]Export { defer h.access.Unlock() out := make(map[string]Export, len(h.exports)) for busid, exp := range h.exports { + if exp.stale { + continue + } out[busid] = exp } return out @@ -343,7 +490,7 @@ func (h *linuxExportHost) releaseExport(exp *linuxExport, restore bool) error { return err } if !restore { - h.logger.Info("removed export state for disappeared device ", exp.busid) + h.logger.Info("removed export state for ", exp.busid) return nil } if exp.originalDriver == "" { @@ -362,6 +509,7 @@ func (h *linuxExportHost) newExport(descriptor sysfsDevice, managed bool, origin return &linuxExport{ busid: descriptor.BusID, descriptor: descriptor, + identity: newLinuxExportIdentity(descriptor), managed: managed, originalDriver: originalDriver, logger: h.logger, @@ -373,20 +521,39 @@ func (h *linuxExportHost) newExport(descriptor sysfsDevice, managed bool, origin type linuxExport struct { busid string descriptor sysfsDevice + identity linuxExportIdentity managed bool originalDriver string logger log.ContextLogger + stale bool } func (e *linuxExport) BusID() string { return e.busid } +func (e *linuxExport) LeaseIdentity() ExportLeaseIdentity { + return e.identity.LeaseIdentity() +} + func (e *linuxExport) Snapshot(ctx context.Context, busy bool) ExportSnapshot { stableID := "linux-busid:" + e.descriptor.BusID if e.descriptor.Serial != "" { stableID = fmt.Sprintf("usb:%04x:%04x:%s", e.descriptor.VendorID, e.descriptor.ProductID, e.descriptor.Serial) } + if e.stale { + return ExportSnapshot{ + Entry: DeviceEntry{ + Info: e.descriptor.toProtocol(), + Interfaces: e.descriptor.Interfaces, + Serial: e.descriptor.Serial, + }, + Backend: backendIDLinuxSysfs, + StableID: stableID, + State: deviceStateUnavailable, + StatusReason: "device replaced", + } + } status, statusErr := readUsbipStatus(e.busid) var state, reason string switch { @@ -422,6 +589,9 @@ func (e *linuxExport) Snapshot(ctx context.Context, busy bool) ExportSnapshot { } func (e *linuxExport) LeaseCheck(ctx context.Context) (bool, string) { + if e.stale { + return false, "device replaced" + } status, err := readUsbipStatus(e.busid) if err != nil { return false, err.Error() @@ -437,6 +607,9 @@ func (e *linuxExport) DeviceInfo(ctx context.Context) (DeviceInfoTruncated, erro } func (e *linuxExport) NewServerDataSession(ctx context.Context, conn net.Conn) (DataSession, error) { + if e.stale { + return nil, E.New("linux export ", e.busid, " is stale") + } handoff, err := newKernelHandoffSession(ctx, conn, e.logger, "server", e.busid) if err != nil { return nil, E.Cause(err, "prepare handoff") @@ -458,6 +631,17 @@ func (e *linuxExport) NewServerDataSession(ctx context.Context, conn net.Conn) ( return handoff, nil } +func (e *linuxExport) shouldRestoreCurrentDevice() bool { + if e.originalDriver == "" || e.stale { + return false + } + descriptor, err := readSysfsDevice(e.busid, filepath.Join(sysBusUSBDevices, e.busid)) + if err != nil { + return false + } + return e.identity.Equal(newLinuxExportIdentity(descriptor)) +} + type linuxImportHost struct { logger log.ContextLogger diff --git a/service/usbip/server.go b/service/usbip/server.go index 9bc26af90..556f71ef8 100644 --- a/service/usbip/server.go +++ b/service/usbip/server.go @@ -282,7 +282,7 @@ func (s *ServerService) handleImportExt(conn net.Conn) bool { s.logger.Debug("read import-ext body: ", err) return false } - export, ok, reason := s.ledger.ConsumeLeaseAndReserve(request) + export, ok, reason := s.ledger.ConsumeLeaseAndReserve(s.ctx, request) if !ok { s.logger.Info("import-ext rejected (", request.BusID, ": ", reason, ")") _ = WriteOpRepImport(conn, OpRepImportExt, OpStatusError, nil) diff --git a/service/usbip/usbhost_darwin.go b/service/usbip/usbhost_darwin.go index 9230ab701..22ec9b1ec 100644 --- a/service/usbip/usbhost_darwin.go +++ b/service/usbip/usbhost_darwin.go @@ -529,6 +529,8 @@ func darwinIOReturnToUSBIPStatus(status int32) int32 { return 0 } switch status { + case int32(C.kIOUSBPipeStalled), int32(C.kUSBHostReturnPipeStalled): + return -int32(unix.EPIPE) case int32(C.kIOReturnAborted), int32(C.kIOReturnNotResponding): return -int32(unix.ECONNRESET) case int32(C.kIOReturnNoDevice), int32(C.kIOReturnOffline): @@ -551,6 +553,8 @@ func darwinUSBIPStatusToCIStatus(status int32) int { return int(C.IOUSBHostCIMessageStatusSuccess) } switch -status { + case int32(unix.EPIPE): + return int(C.IOUSBHostCIMessageStatusStallError) case int32(unix.ETIMEDOUT): return int(C.IOUSBHostCIMessageStatusTimeout) case int32(unix.ENOMEM): diff --git a/service/usbip/usbhost_darwin.h b/service/usbip/usbhost_darwin.h index c6df610e1..58884e807 100644 --- a/service/usbip/usbhost_darwin.h +++ b/service/usbip/usbhost_darwin.h @@ -3,6 +3,8 @@ #include #include +#include +#include #define BOX_USBHOST_MAX_INTERFACES 255 diff --git a/service/usbip/usbhost_darwin.m b/service/usbip/usbhost_darwin.m index cf6db1752..20f4a7828 100644 --- a/service/usbip/usbhost_darwin.m +++ b/service/usbip/usbhost_darwin.m @@ -331,6 +331,101 @@ static IOUSBHostInterface *box_interface_for_number(BoxUSBHostDevice *box, uint8 return nil; } +static void box_refresh_interfaces(BoxUSBHostDevice *box) { + [box withStateLock:^{ + box.pipes = [NSMutableDictionary dictionary]; + box_load_interfaces(box); + }]; +} + +static uint8_t box_request_direction(IOUSBDeviceRequest request) { + return request.bmRequestType & kIOUSBDeviceRequestDirectionMask; +} + +static uint8_t box_request_type(IOUSBDeviceRequest request) { + return request.bmRequestType & kIOUSBDeviceRequestTypeMask; +} + +static uint8_t box_request_recipient(IOUSBDeviceRequest request) { + return request.bmRequestType & kIOUSBDeviceRequestRecipientMask; +} + +static BOOL box_request_is_set_configuration(IOUSBDeviceRequest request) { + return box_request_direction(request) == kIOUSBDeviceRequestDirectionOut && + box_request_type(request) == kIOUSBDeviceRequestTypeStandard && + box_request_recipient(request) == kIOUSBDeviceRequestRecipientDevice && + request.bRequest == kIOUSBDeviceRequestSetConfiguration && + request.wIndex == 0 && + request.wLength == 0; +} + +static BOOL box_request_is_set_interface(IOUSBDeviceRequest request) { + return box_request_direction(request) == kIOUSBDeviceRequestDirectionOut && + box_request_type(request) == kIOUSBDeviceRequestTypeStandard && + box_request_recipient(request) == kIOUSBDeviceRequestRecipientInterface && + request.bRequest == kIOUSBDeviceRequestSetInterface && + request.wLength == 0; +} + +static BOOL box_request_is_clear_endpoint_halt(IOUSBDeviceRequest request) { + return box_request_direction(request) == kIOUSBDeviceRequestDirectionOut && + box_request_type(request) == kIOUSBDeviceRequestTypeStandard && + box_request_recipient(request) == kIOUSBDeviceRequestRecipientEndpoint && + request.bRequest == kIOUSBDeviceRequestClearFeature && + request.wValue == IOUSBEndpointFeatureSelectorStall && + request.wLength == 0; +} + +static BOOL box_handle_set_configuration(BoxUSBHostDevice *box, IOUSBDeviceRequest request, NSError **error) { + BOOL ok = [box.device configureWithValue:request.wValue matchInterfaces:YES error:error]; + if (ok) { + box_refresh_interfaces(box); + } + return ok; +} + +static BOOL box_handle_set_interface(BoxUSBHostDevice *box, IOUSBDeviceRequest request, NSError **error) { + __block IOUSBHostInterface *interface = nil; + [box withStateLock:^{ + interface = box_interface_for_number(box, request.wIndex & 0xff); + }]; + if (interface == nil) { + *error = [NSError errorWithDomain:NSMachErrorDomain code:kIOReturnNotFound userInfo:nil]; + return NO; + } + BOOL ok = [interface selectAlternateSetting:request.wValue error:error]; + if (ok) { + box_refresh_interfaces(box); + } + return ok; +} + +static BOOL box_handle_clear_endpoint_halt(BoxUSBHostDevice *box, IOUSBDeviceRequest request, NSError **error) { + uint8_t endpoint = (uint8_t)(request.wIndex & 0xff); + if ((endpoint & kIOUSBEndpointDescriptorNumber) == 0) { + return [box.device sendDeviceRequest:request data:nil bytesTransferred:NULL completionTimeout:IOUSBHostDefaultControlCompletionTimeout error:error]; + } + IOUSBHostPipe *pipe = box_pipe_for_endpoint(box, endpoint); + if (pipe == nil) { + *error = [NSError errorWithDomain:NSMachErrorDomain code:kIOReturnNotFound userInfo:nil]; + return NO; + } + return [pipe clearStallWithError:error]; +} + +static BOOL box_dispatch_control_request(BoxUSBHostDevice *box, IOUSBDeviceRequest request, NSMutableData *payload, NSUInteger *actual, NSError **error) { + if (box_request_is_set_configuration(request)) { + return box_handle_set_configuration(box, request, error); + } + if (box_request_is_set_interface(request)) { + return box_handle_set_interface(box, request, error); + } + if (box_request_is_clear_endpoint_halt(request)) { + return box_handle_clear_endpoint_halt(box, request, error); + } + return [box.device sendDeviceRequest:request data:payload bytesTransferred:actual completionTimeout:IOUSBHostDefaultControlCompletionTimeout error:error]; +} + bool box_usbhost_copy_devices(box_usbhost_device_list_t *out, char **error_out) { if (out == NULL) { box_set_error_string(error_out, @"IOUSBHost copy devices: missing output"); @@ -521,34 +616,7 @@ bool box_usbhost_device_control(box_usbhost_device_t *device, const uint8_t setu } NSError *error = nil; NSUInteger actual = 0; - BOOL ok = NO; - if (request.bmRequestType == 0 && request.bRequest == kIOUSBDeviceRequestSetConfiguration && request.wIndex == 0 && request.wLength == 0) { - ok = [box.device configureWithValue:request.wValue matchInterfaces:YES error:&error]; - if (ok) { - [box withStateLock:^{ - box.pipes = [NSMutableDictionary dictionary]; - box_load_interfaces(box); - }]; - } - } else if (request.bmRequestType == kIOUSBDeviceRequestRecipientInterface && request.bRequest == kIOUSBDeviceRequestSetInterface && request.wLength == 0) { - __block IOUSBHostInterface *interface = nil; - [box withStateLock:^{ - interface = box_interface_for_number(box, request.wIndex & 0xff); - }]; - if (interface == nil) { - error = [NSError errorWithDomain:NSMachErrorDomain code:kIOReturnNotFound userInfo:nil]; - } else { - ok = [interface selectAlternateSetting:request.wValue error:&error]; - if (ok) { - [box withStateLock:^{ - box.pipes = [NSMutableDictionary dictionary]; - box_load_interfaces(box); - }]; - } - } - } else { - ok = [box.device sendDeviceRequest:request data:payload bytesTransferred:&actual completionTimeout:IOUSBHostDefaultControlCompletionTimeout error:&error]; - } + BOOL ok = box_dispatch_control_request(box, request, payload, &actual, &error); if (actual_out != NULL) { *actual_out = actual; } diff --git a/service/usbip/usbhost_darwin_status_test.go b/service/usbip/usbhost_darwin_status_test.go new file mode 100644 index 000000000..4185e13a2 --- /dev/null +++ b/service/usbip/usbhost_darwin_status_test.go @@ -0,0 +1,32 @@ +//go:build darwin && cgo + +package usbip + +import ( + "testing" + + "github.com/stretchr/testify/require" + "golang.org/x/sys/unix" +) + +const ( + // From IOKit/usb/USB.h: kIOUSBPipeStalled + darwinIOReturnPipeStalled int32 = -536854449 + // From IOKit/usb/IOUSBHostFamilyDefinitions.h: kUSBHostReturnPipeStalled + darwinUSBHostReturnPipeStalled int32 = -536850432 +) + +func TestDarwinIOReturnToUSBIPStatusMapsStall(t *testing.T) { + testCases := []int32{ + darwinIOReturnPipeStalled, + darwinUSBHostReturnPipeStalled, + } + + for _, status := range testCases { + require.Equal(t, -int32(unix.EPIPE), darwinIOReturnToUSBIPStatus(status)) + } +} + +func TestDarwinUSBIPStatusToCIStatusMapsStall(t *testing.T) { + require.Equal(t, ciStatusStallError, darwinUSBIPStatusToCIStatus(-int32(unix.EPIPE))) +}