From d6108e4ae73f1c5a7bed7bf26232d6665df7b013 Mon Sep 17 00:00:00 2001 From: Giova Date: Wed, 30 Sep 2026 00:33:24 +0200 Subject: [PATCH 1/2] usb: fix SuperSpeedPlus passthrough and reconnect saved devices USB 3.2 Gen 2 (10 Gbps) devices were reported by libusb at a speed the usb-redir protocol cannot express, so QEMU attached them at full speed while forwarding their SuperSpeed descriptors. Guests rejected them with "Invalid ep0 maxpacket: 9" and "unable to enumerate USB device". - present LIBUSB_SPEED_SUPER_PLUS as SuperSpeed to the guest - apply QEMU's bMaxPacketSize0 fix-up whenever the device is presented at a non-SuperSpeed link - unmount the host volumes of a mass storage device before redirecting it - reconnect a saved device when it appears while the virtual machine runs Resolves #7530 Assisted-by: OpenCode:deepseek-v4.1-flash --- .../VMDisplayQemuDisplayController.swift | 41 ++- Scripting/UTMScriptingUSBDeviceImpl.swift | 2 +- Services/UTMUSBDeviceUnmount.swift | 288 ++++++++++++++++++ UTM.xcodeproj/project.pbxproj | 4 + patches/qemu-10.0.12-utm.patch | 46 +++ patches/usbredir-0.14.0.patch | 46 +++ 6 files changed, 413 insertions(+), 14 deletions(-) create mode 100644 Services/UTMUSBDeviceUnmount.swift create mode 100644 patches/usbredir-0.14.0.patch diff --git a/Platform/macOS/Display/VMDisplayQemuDisplayController.swift b/Platform/macOS/Display/VMDisplayQemuDisplayController.swift index 8bfc42b063..22593440dc 100644 --- a/Platform/macOS/Display/VMDisplayQemuDisplayController.swift +++ b/Platform/macOS/Display/VMDisplayQemuDisplayController.swift @@ -328,11 +328,31 @@ extension VMDisplayQemuWindowController: CSUSBManagerDelegate { func spiceUsbManager(_ usbManager: CSUSBManager, deviceAttached device: CSUSBDevice) { logger.debug("USB device attached: \(device)") - if !isNoUsbPrompt { - Task { @MainActor in - if self.window!.isKeyWindow && self.vm.state == .started { - self.showConnectPrompt(for: device) - } + Task { @MainActor in + // A device saved for this virtual machine is connected again without prompting, + // including one that reset itself, for example while updating its firmware. + if self.isAutoConnect(device) { + self.autoConnect(device, with: usbManager) + return + } + if !self.isNoUsbPrompt && self.window!.isKeyWindow && self.vm.state == .started { + self.showConnectPrompt(for: device) + } + } + } + + /// Connect a device the user saved for this virtual machine + private func autoConnect(_ device: CSUSBDevice, with usbManager: CSUSBManager) { + guard !isSecondary, vm.state == .started else { + return + } + withErrorAlert { [self] in + guard !usbManager.isUsbDeviceConnected(device) else { + return + } + try await usbManager.prepareAndConnectUsbDevice(device) + await MainActor.run { + self.connectedUsbDevices.append(device) } } } @@ -366,7 +386,7 @@ extension VMDisplayQemuWindowController: CSUSBManagerDelegate { } Task.detached { do { - try await usbManager.connectUsbDevice(usbDevice) + try await usbManager.prepareAndConnectUsbDevice(usbDevice) await MainActor.run { self.connectedUsbDevices.append(usbDevice) } @@ -464,7 +484,7 @@ extension VMDisplayQemuWindowController { let device = allUsbDevices[menu.tag] Task.detached { self.withErrorAlert { - try await usbManager.connectUsbDevice(device) + try await usbManager.prepareAndConnectUsbDevice(device) await MainActor.run { self.primaryDisplayController.connectedUsbDevices.append(device) } @@ -516,12 +536,7 @@ extension VMDisplayQemuWindowController { } let filtered = devices.filter({ self.isAutoConnect($0) }) for device in filtered { - self.withErrorAlert { - try await usbManager.connectUsbDevice(device) - await MainActor.run { - self.connectedUsbDevices.append(device) - } - } + self.autoConnect(device, with: usbManager) } } } diff --git a/Scripting/UTMScriptingUSBDeviceImpl.swift b/Scripting/UTMScriptingUSBDeviceImpl.swift index bf06a04b6c..052d9e27ce 100644 --- a/Scripting/UTMScriptingUSBDeviceImpl.swift +++ b/Scripting/UTMScriptingUSBDeviceImpl.swift @@ -136,7 +136,7 @@ class UTMScriptingUSBDeviceImpl: NSObject, UTMScriptable { guard let usbDevice = same(usbDevice: box, for: usbManager) else { throw ScriptingError.deviceNotFound } - try await usbManager.connectUsbDevice(usbDevice) + try await usbManager.prepareAndConnectUsbDevice(usbDevice) } else if #available(macOS 27, *), let vm = scriptingVM?.vm as? UTMAppleVirtualMachine { guard vm.hasUsbRedirection else { throw UTMScriptingVirtualMachineImpl.ScriptingError.operationNotAvailable diff --git a/Services/UTMUSBDeviceUnmount.swift b/Services/UTMUSBDeviceUnmount.swift new file mode 100644 index 0000000000..6251ccb777 --- /dev/null +++ b/Services/UTMUSBDeviceUnmount.swift @@ -0,0 +1,288 @@ +// +// Copyright © 2026 Turing Software, LLC. All rights reserved. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// + +#if os(macOS) +import Foundation +import IOKit +import DiskArbitration +import CocoaSpice + +/// Unmounts the host volumes of a USB device before it is redirected to a guest. +/// +/// While any volume of a mass storage device is mounted, macOS keeps the device busy and +/// redirecting it would terminate the storage driver without flushing the volumes. Unmounting +/// first detaches the volumes cleanly so the device can be captured by the guest without +/// losing data. Devices without mounted volumes are left untouched. +enum UTMUSBDeviceUnmount { + /// How long to wait for a volume to unmount before giving up + private static let unmountTimeout: UInt64 = 10 * NSEC_PER_SEC + + /// Unmount every mounted volume of a host USB device + /// + /// Does nothing when the device has no mounted volumes or cannot be found in the IORegistry. + /// - Parameter device: USB device about to be redirected to a guest + /// - Throws: `UTMUSBDeviceUnmountError` when a volume cannot be unmounted + static func unmountVolumes(for device: CSUSBDevice) async throws { + guard let service = matchingService(for: device) else { + logger.debug("USB device \(device) not found in the IORegistry, not unmounting") + return + } + defer { + IOObjectRelease(service) + } + for bsdName in bsdNames(ofMediaUnder: service) { + try await unmount(bsdName: bsdName) + } + } + + /// Add a hint to the errors macOS reports when it will not release a device + static func redirectError(_ error: any Error) -> any Error { + let description = error.localizedDescription + let inUseErrors = ["LIBUSB_ERROR_ACCESS", "LIBUSB_ERROR_BUSY", "in use by another application"] + guard inUseErrors.contains(where: { description.contains($0) }) else { + return error + } + return UTMUSBDeviceUnmountError.cannotClaim(description) + } + + // MARK: - IORegistry + + /// Find the IORegistry entry of a USB device + /// + /// The device is matched by vendor and product ID, then narrowed down with the serial + /// number and the bus number when they are available. An ambiguous match is treated as + /// no match so the wrong device is never unmounted. + private static func matchingService(for device: CSUSBDevice) -> io_service_t? { + var candidates: [io_service_t] = [] + let matching = IOServiceMatching("IOUSBDevice") ?? IOServiceMatching("IOUSBHostDevice") + var iterator: io_iterator_t = 0 + guard let matching = matching, + IOServiceGetMatchingServices(kIOMainPortDefault, matching, &iterator) == KERN_SUCCESS else { + return nil + } + defer { + IOObjectRelease(iterator) + } + while true { + let service = IOIteratorNext(iterator) + guard service != IO_OBJECT_NULL else { + break + } + if (property(of: service, named: "idVendor") as? NSNumber)?.intValue == device.usbVendorId, + (property(of: service, named: "idProduct") as? NSNumber)?.intValue == device.usbProductId { + candidates.append(service) + } else { + IOObjectRelease(service) + } + } + if let serial = device.usbSerial, !serial.isEmpty { + candidates.removeAll { (property(of: $0, named: "USB Serial Number") as? String) != serial } + } + if candidates.count > 1 { + let bus = device.usbBusNumber + candidates.removeAll { ((property(of: $0, named: "locationID") as? NSNumber)?.intValue ?? 0) >> 24 != bus } + } + guard candidates.count == 1 else { + candidates.forEach { IOObjectRelease($0) } + return nil + } + return candidates[0] + } + + /// Collect the BSD names of all media below an IORegistry entry + private static func bsdNames(ofMediaUnder service: io_service_t) -> [String] { + var bsdNames: [String] = [] + // The loop below releases every entry it pops, so hold a reference on the root + IOObjectRetain(service) + var queue: [(entry: io_service_t, depth: Int)] = [(service, 0)] + while let (entry, depth) = queue.popLast() { + defer { + IOObjectRelease(entry) + } + if depth > 0, + IOObjectConformsTo(entry, "IOMedia") != 0, + let bsdName = property(of: entry, named: "BSD Name") as? String { + bsdNames.append(bsdName) + } + guard depth < 16 else { + continue + } + var iterator: io_iterator_t = 0 + guard IORegistryEntryGetChildIterator(entry, kIOServicePlane, &iterator) == KERN_SUCCESS else { + continue + } + while true { + let child = IOIteratorNext(iterator) + guard child != IO_OBJECT_NULL else { + break + } + queue.append((child, depth + 1)) + } + IOObjectRelease(iterator) + } + return bsdNames + } + + private static func property(of service: io_service_t, named name: String) -> Any? { + IORegistryEntryCreateCFProperty(service, name as CFString, kCFAllocatorDefault, 0)?.takeRetainedValue() + } + + // MARK: - DiskArbitration + + /// Unmount a volume, treating an already unmounted volume as success + private static func unmount(bsdName: String) async throws { + guard let session = DASessionCreate(kCFAllocatorDefault), + let disk = DADiskCreateFromBSDName(kCFAllocatorDefault, session, bsdName) else { + return + } + guard let description = DADiskCopyDescription(disk) else { + return + } + guard (description as NSDictionary)[kDADiskDescriptionVolumePathKey] != nil else { + return // the disk is not a mounted volume + } + let queue = DispatchQueue(label: "com.utmapp.UTM.USBUnmount") + DASessionSetDispatchQueue(session, queue) + let continuation = UnmountContinuation() + try await withTaskCancellationHandler { + try await withCheckedThrowingContinuation { (checkedContinuation: CheckedContinuation) in + continuation.set(checkedContinuation) + let context = UnmountContext(continuation: continuation, bsdName: bsdName) + DADiskUnmount(disk, DADiskUnmountOptions(kDADiskUnmountOptionDefault), { _, dissenter, pointer in + guard let pointer = pointer else { + return + } + let context = Unmanaged.fromOpaque(pointer).takeRetainedValue() + if let dissenter = dissenter, DADissenterGetStatus(dissenter) != kDAReturnNotMounted { + let message = DADissenterGetStatusString(dissenter) as String? + context.continuation.resume(throwing: UTMUSBDeviceUnmountError.volumeInUse(context.bsdName, message)) + } else { + context.continuation.resume() + } + }, Unmanaged.passRetained(context).toOpaque()) + Task { + try? await Task.sleep(nanoseconds: unmountTimeout) + continuation.resume(throwing: UTMUSBDeviceUnmountError.timeout(bsdName)) + } + } + } onCancel: { + continuation.resume(throwing: CancellationError()) + } + } +} + +// MARK: - Errors + +enum UTMUSBDeviceUnmountError: LocalizedError { + /// The volume is still in use by the host + case volumeInUse(String, String?) + /// The volume did not unmount in time + case timeout(String) + /// The device could not be claimed even after unmounting + case cannotClaim(String) + + var errorDescription: String? { + switch self { + case .volumeInUse(let bsdName, let message): + let format = NSLocalizedString("The volume '%@' is in use by macOS and could not be ejected. Eject it in the Finder and try again.", comment: "UTMUSBDeviceUnmount") + return String.localizedStringWithFormat(format, bsdName) + (message.map { " (\($0))" } ?? "") + case .timeout(let bsdName): + let format = NSLocalizedString("The volume '%@' did not finish ejecting from macOS.", comment: "UTMUSBDeviceUnmount") + return String.localizedStringWithFormat(format, bsdName) + case .cannotClaim(let message): + let format = NSLocalizedString("The device is still in use by macOS and cannot be connected to the virtual machine. Eject any of its volumes in the Finder, allow USB access when macOS asks, and try again.\n\n%@", comment: "UTMUSBDeviceUnmount") + return String.localizedStringWithFormat(format, message) + } + } +} + +/// Resumes a continuation exactly once from a DiskArbitration callback or a timeout +private final class UnmountContinuation: @unchecked Sendable { + private let lock = NSLock() + private var continuation: CheckedContinuation? + + func set(_ continuation: CheckedContinuation) { + lock.lock() + defer { + lock.unlock() + } + if let error = pendingError { + pendingError = nil + continuation.resume(throwing: error) + } else { + self.continuation = continuation + } + } + + func resume() { + resume(with: .success(())) + } + + func resume(throwing error: any Error) { + resume(with: .failure(error)) + } + + private func resume(with result: Result) { + lock.lock() + defer { + lock.unlock() + } + guard let continuation = continuation else { + if case .failure(let error) = result { + pendingError = error + } + return + } + self.continuation = nil + continuation.resume(with: result) + } + + /// Error that arrived before the continuation was set, such as a cancellation + private var pendingError: (any Error)? +} + +/// State passed to a DiskArbitration callback +/// +/// The callback cannot capture context, so everything it needs is passed through the +/// callback's context pointer instead. +private final class UnmountContext: @unchecked Sendable { + let continuation: UnmountContinuation + let bsdName: String + + init(continuation: UnmountContinuation, bsdName: String) { + self.continuation = continuation + self.bsdName = bsdName + } +} + +// MARK: - CSUSBManager + +extension CSUSBManager { + /// Eject the host volumes of a USB device and then redirect it to the guest + /// + /// Unmounting the volumes first lets the guest capture a mass storage device without + /// the host terminating its storage driver while the volumes are still in use. + /// - Parameter device: USB device to redirect + func prepareAndConnectUsbDevice(_ device: CSUSBDevice) async throws { + try await UTMUSBDeviceUnmount.unmountVolumes(for: device) + do { + try await connectUsbDevice(device) + } catch { + throw UTMUSBDeviceUnmount.redirectError(error) + } + } +} +#endif \ No newline at end of file diff --git a/UTM.xcodeproj/project.pbxproj b/UTM.xcodeproj/project.pbxproj index d72c01b849..a834099061 100644 --- a/UTM.xcodeproj/project.pbxproj +++ b/UTM.xcodeproj/project.pbxproj @@ -697,6 +697,7 @@ CE65BABF26A4D8DD0001BD6B /* VMConfigDisplayConsoleView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8401FDA5269D44E400265F0D /* VMConfigDisplayConsoleView.swift */; }; CE65BAC026A4D8DE0001BD6B /* VMConfigDisplayConsoleView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8401FDA5269D44E400265F0D /* VMConfigDisplayConsoleView.swift */; }; CE6804802E493D71001671E9 /* UTMUSBManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = CE68047D2E493D71001671E9 /* UTMUSBManager.swift */; }; + 83EEE570616BAC210F7FDFA5 /* UTMUSBDeviceUnmount.swift in Sources */ = {isa = PBXBuildFile; fileRef = 468649F8E4C55D7A49FB9139 /* UTMUSBDeviceUnmount.swift */; }; CE6804852E4E5D84001671E9 /* UTMScriptingInputImpl.swift in Sources */ = {isa = PBXBuildFile; fileRef = CE6804842E4E5D84001671E9 /* UTMScriptingInputImpl.swift */; }; CE68E5442E3912E0006B3645 /* VMKeyboardShortcutsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = CE68E5422E3912E0006B3645 /* VMKeyboardShortcutsView.swift */; }; CE68E5452E3912E0006B3645 /* VMKeyboardShortcutsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = CE68E5422E3912E0006B3645 /* VMKeyboardShortcutsView.swift */; }; @@ -2120,6 +2121,7 @@ CE63B0F02F0ED67700C59D13 /* vulkan */ = {isa = PBXFileReference; lastKnownFileType = text.json; name = vulkan; path = "$(SYSROOT_DIR)/share/vulkan"; sourceTree = ""; }; CE66450C2269313200B0849A /* MetalKit.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = MetalKit.framework; path = System/Library/Frameworks/MetalKit.framework; sourceTree = SDKROOT; }; CE68047D2E493D71001671E9 /* UTMUSBManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UTMUSBManager.swift; sourceTree = ""; }; + 468649F8E4C55D7A49FB9139 /* UTMUSBDeviceUnmount.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UTMUSBDeviceUnmount.swift; sourceTree = ""; }; CE6804842E4E5D84001671E9 /* UTMScriptingInputImpl.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UTMScriptingInputImpl.swift; sourceTree = ""; }; CE68E5422E3912E0006B3645 /* VMKeyboardShortcutsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VMKeyboardShortcutsView.swift; sourceTree = ""; }; CE68E5472E3C3E0A006B3645 /* VMWizardOSClassicMacView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VMWizardOSClassicMacView.swift; sourceTree = ""; }; @@ -3115,6 +3117,7 @@ E2D64BE0241EAEBE0034E0C6 /* UTMSpiceIODelegate.h */, 845F95E22A57628400A016D7 /* UTMSWTPM.swift */, CE68047D2E493D71001671E9 /* UTMUSBManager.swift */, + 468649F8E4C55D7A49FB9139 /* UTMUSBDeviceUnmount.swift */, CE7A1C012EFA0A01001671E9 /* UTMAppleUSBDevice.swift */, CE7A1C032EFA0A01001671E9 /* UTMAppleUSBManager.swift */, CE1D15AA2F6B100100D15C01 /* UTMAppleDiskImage.swift */, @@ -4388,6 +4391,7 @@ 848A98B4286A1215006F0550 /* UTMAppleConfigurationVirtualization.swift in Sources */, 843BF842284555E70029D60D /* UTMQemuConfigurationPortForward.swift in Sources */, CE6804802E493D71001671E9 /* UTMUSBManager.swift in Sources */, + 83EEE570616BAC210F7FDFA5 /* UTMUSBDeviceUnmount.swift in Sources */, CE7A1C022EFA0A01001671E9 /* UTMAppleUSBDevice.swift in Sources */, CE7A1C042EFA0A01001671E9 /* UTMAppleUSBManager.swift in Sources */, CE1D15AB2F6B100100D15C01 /* UTMAppleDiskImage.swift in Sources */, diff --git a/patches/qemu-10.0.12-utm.patch b/patches/qemu-10.0.12-utm.patch index fadb8041f6..12cb92e372 100644 --- a/patches/qemu-10.0.12-utm.patch +++ b/patches/qemu-10.0.12-utm.patch @@ -2833,3 +2833,49 @@ index c4323574e1..fba1a56ceb 100644 -- 2.54.0 (Apple Git-157) + +From 9f0b3fac327103a9eadcd3c5b4030f0cb9d96e41 Mon Sep 17 00:00:00 2001 +From: UTM contributors +Date: Mon, 28 Sep 2026 12:00:00 +0000 +Subject: [PATCH] usb-redir: fix SuperSpeed descriptors on non-SuperSpeed links + +A USB 3.x device can be forwarded to a guest that enumerates it at a +lower speed: when the host reports a speed the usb-redir protocol cannot +express (SuperSpeedPlus is sent as "unknown" speed), when the device is +attached to a USB 2.0 bus, or when the port cannot do SuperSpeed. + +The existing fix-up only rewrote the SuperSpeed bMaxPacketSize0 value +when QEMU itself had marked the device as SuperSpeed. Also do it when the +device is attached at any other speed, which is exactly when the +forwarded SuperSpeed descriptor is inconsistent. Guests then fail to +enumerate the device with "Invalid ep0 maxpacket: 9" and "unable to +enumerate USB device" instead of falling back to the lower speed. + +Assisted-by: DeepSeek:deepseek-v4.1-flash +--- + hw/usb/redirect.c | 12 +++++++++--- + 1 file changed, 9 insertions(+), 3 deletions(-) + +diff --git a/hw/usb/redirect.c b/hw/usb/redirect.c +index 999a690..c034b19 100644 +--- a/hw/usb/redirect.c ++++ b/hw/usb/redirect.c +@@ -2003,9 +2003,15 @@ static void usbredir_control_packet(void *priv, uint64_t id, + len, id); + + /* Fix up USB-3 ep0 maxpacket size to allow superspeed connected devices +- * to work redirected to a not superspeed capable hcd */ +- if (dev->dev.speed == USB_SPEED_SUPER && +- !((dev->dev.port->speedmask & USB_SPEED_MASK_SUPER)) && ++ * to work redirected to a not superspeed capable hcd ++ * ++ * This also catches devices the usb-redir client reported at a lower ++ * speed (for example a SuperSpeedPlus device reported as unknown speed, ++ * which attaches as full speed) while their SuperSpeed descriptor is ++ * still forwarded. The guest would otherwise reject the descriptor with ++ * "Invalid ep0 maxpacket: 9" and fail to enumerate the device. */ ++ if (!(dev->dev.speed == USB_SPEED_SUPER && ++ (dev->dev.port->speedmask & USB_SPEED_MASK_SUPER)) && + control_packet->requesttype == 0x80 && + control_packet->request == 6 && + control_packet->value == 0x100 && control_packet->index == 0 && diff --git a/patches/usbredir-0.14.0.patch b/patches/usbredir-0.14.0.patch new file mode 100644 index 0000000000..0e1f125835 --- /dev/null +++ b/patches/usbredir-0.14.0.patch @@ -0,0 +1,46 @@ +From 5f0f2f5f9d5f0f2f5f9d5f0f2f5f9d5f0f2f5f9d Mon Sep 17 00:00:00 2001 +From: UTM contributors +Date: Mon, 28 Sep 2026 12:00:00 +0000 +Subject: [PATCH] usbredirhost: present SuperSpeedPlus devices as SuperSpeed + +USB 3.2 Gen 2 (10 Gbps) devices report LIBUSB_SPEED_SUPER_PLUS, which +the usb-redir protocol cannot express. The speed was reported as +"unknown", which QEMU interprets as full speed while it still forwards +the device's SuperSpeed descriptors (bMaxPacketSize0 == 9). Guests then +fail to enumerate the device with errors such as "Invalid ep0 maxpacket: +9" and "unable to enumerate USB device". + +This affected many external SSDs and NVMe enclosures. A 5 Gbps USB 3.0 +device reports LIBUSB_SPEED_SUPER and worked, and forcing the device to +USB 2.0 (for example with a USB 2.0 hub) also worked, which is why only +SuperSpeedPlus devices were reported broken. + +Present SuperSpeedPlus devices as SuperSpeed instead. The guest then +enumerates the device with a matching SuperSpeed link and descriptors, +capped at 5 Gbps. + +Assisted-by: DeepSeek:deepseek-v4.1-flash +--- + usbredirhost/usbredirhost.c | 7 +++++++ + 1 file changed, 7 insertions(+) + +diff --git a/usbredirhost/usbredirhost.c b/usbredirhost/usbredirhost.c +index 3b6f0c1..5a2d4e8 100644 +--- a/usbredirhost/usbredirhost.c ++++ b/usbredirhost/usbredirhost.c +@@ -421,6 +421,13 @@ + case LIBUSB_SPEED_HIGH: + device_connect.speed = usb_redir_speed_high; break; + case LIBUSB_SPEED_SUPER: ++#if LIBUSB_API_VERSION >= 0x01000106 ++ case LIBUSB_SPEED_SUPER_PLUS: ++#endif ++ /* The usb-redir protocol has no SuperSpeedPlus speed, so present ++ * the device as SuperSpeed. Reporting it as unknown speed instead ++ * makes QEMU attach it at full speed while forwarding its SuperSpeed ++ * descriptors, and the guest then fails to enumerate it. */ + device_connect.speed = usb_redir_speed_super; break; + default: + device_connect.speed = usb_redir_speed_unknown; +-- +2.41.0 \ No newline at end of file From a768d7aac0762c5423d5037d9af88f627e9c5f65 Mon Sep 17 00:00:00 2001 From: Giova Date: Fri, 2 Oct 2026 18:14:18 +0200 Subject: [PATCH 2/2] usb: cancel UAS bulk stream transfers on macOS Once a USB 3.2 Gen 2 device enumerates at SuperSpeed, the guest drives it as a UAS (USB Attached SCSI) device, where the command tag is the USB 3 bulk stream id, so usbredirhost cancels bulk stream transfers constantly. libusb's Darwin backend did not handle LIBUSB_TRANSFER_TYPE_BULK_STREAM in darwin_cancel_transfer(), so every cancel returned LIBUSB_ERROR_INVALID_PARAM ("unknown endpoint type 4"). The cancelled transfers stayed in flight on the host controller: the first UAS commands timed out (30 second SCSI timeout, uas_eh_abort_handler, repeated device resets), and the device was left captured by macOS after the virtual machine stopped, until it was physically replugged. darwin_abort_transfers() already aborts a single bulk stream with AbortStreamsPipe(); route BULK_STREAM to it. The same omission is still present in upstream libusb master. Resolves #7530 Assisted-by: OpenCode:deepseek-v4.1-flash --- patches/libusb-1.0.25.patch | 37 +++++++++++++++++++++++++++++++++++++ 1 file changed, 37 insertions(+) diff --git a/patches/libusb-1.0.25.patch b/patches/libusb-1.0.25.patch index b184bb0ba0..37ad7acb41 100644 --- a/patches/libusb-1.0.25.patch +++ b/patches/libusb-1.0.25.patch @@ -1042,3 +1042,40 @@ index d385a0e8..6407d7b6 100644 -- 2.41.0 + +From 0000000000000000000000000000000000000000 Mon Sep 17 00:00:00 2001 +From: Giova +Date: Fri, 2 Oct 2026 17:00:00 +0200 +Subject: [PATCH] darwin: allow cancelling bulk stream transfers + +darwin_cancel_transfer() routes bulk, interrupt and isochronous +transfers to darwin_abort_transfers(), which already knows how to abort +a single bulk stream with AbortStreamsPipe(). LIBUSB_TRANSFER_TYPE_BULK_STREAM +was missing from the switch, so libusb_cancel_transfer() returned +LIBUSB_ERROR_INVALID_PARAM for every stream transfer. + +USB Attached SCSI uses the command tag as the bulk stream id, so a +redirection host cancels stream transfers constantly. When the cancel is +not applied the transfer stays in flight on the host controller and the +guest's command never completes, which surfaces as a 30 second SCSI +timeout and a device reset in the guest. + +Assisted-by: OpenCode:deepseek-v4.1-flash +--- + libusb/os/darwin_usb.c | 1 + + 1 file changed, 1 insertion(+) + +diff --git a/libusb/os/darwin_usb.c b/libusb/os/darwin_usb.c +index 6407d7b67004c83f73d5ed867adbb108a1618375..0a672029ef7ad34f14a5bb077e17067b50c9af19 100644 +--- a/libusb/os/darwin_usb.c ++++ b/libusb/os/darwin_usb.c +@@ -2329,6 +2329,7 @@ + case LIBUSB_TRANSFER_TYPE_BULK: + case LIBUSB_TRANSFER_TYPE_INTERRUPT: + case LIBUSB_TRANSFER_TYPE_ISOCHRONOUS: ++ case LIBUSB_TRANSFER_TYPE_BULK_STREAM: + return darwin_abort_transfers (itransfer); + default: + usbi_err (TRANSFER_CTX(transfer), "unknown endpoint type %d", transfer->type); +-- +2.41.0