Skip to content

Commit e0c3f04

Browse files
beriberikixclaude
andcommitted
Reopen an interface whose device went away
Interfaces are opened once and kept, which is right while a device stays put and wrong the moment it does not. A device that disappeared even briefly — re-enumerating, or a phone changing its USB configuration — left the daemon holding a dead IOKit handle, and every transfer through it returned "no device" from then on. Restarting the daemon was the only way back; a fresh one worked immediately, which is how this was noticed against a Pixel. A transfer that reports the device gone now drops the cached interface, so the next request opens a new one. Only that status does: kIOReturnNoDevice and kIOReturnNotResponding both map to it, while a stall or a timeout leaves the interface alone. Discarding on any failure would throw away a working interface on every timeout, and a timeout is the ordinary outcome of reading an endpoint with nothing to say — this project's own concurrency check produces one deliberately. The interface is taken out of the table under the lock and released after it. Letting it deallocate inside would run its deinit, and so close(), and so IOKit calls, while every other transfer waited on that lock. Also fix the release checksums, which have now been wrong twice for the same reason: the glob names the artifacts and the artifacts get renamed. First it matched "*.macos" while binaries ended in "-macos", so checksums-v0.5.0 covered only the tarballs. Then per-architecture builds arrived ending in "-arm64" and "-x86_64", which "*-macos" does not match either, so v0.6.0 shipped with no hash for the two binaries most people download. It now checksums every artifact, which cannot drift out of step with their names, and fails the release if any expected binary is missing from the result. Verified against a mock artifact directory: all eight files covered, and the old glob trips the new guard on exactly the two binaries v0.6.0 omitted. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_017rmyikdjWveP99ZUCDLY89
1 parent e8f03ab commit e0c3f04

3 files changed

Lines changed: 136 additions & 9 deletions

File tree

.github/workflows/release.yml

Lines changed: 25 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -406,11 +406,31 @@ jobs:
406406
CHECKSUMS_FILE="checksums-${{ needs.release-validation.outputs.version }}.sha256"
407407
408408
echo "📊 Generating checksums..."
409-
# Only checksum files, not directories
410-
# The binaries are named usbipd-vX.Y.Z-macos, so they end in "-macos" and the
411-
# old "*.macos" glob never matched one. checksums-v0.5.0.sha256 shipped
412-
# covering only the tarballs, with no hash for the binary anyone downloads.
413-
find . -maxdepth 1 -type f \( -name "*-macos" -o -name "*.tar.gz" \) -exec shasum -a 256 {} \; > "$CHECKSUMS_FILE"
409+
# Checksum every artifact, rather than the ones a pattern happens to name.
410+
#
411+
# This glob has been wrong twice, each time because the artifacts were renamed
412+
# and it was not. First it matched "*.macos" while binaries ended in "-macos",
413+
# so checksums-v0.5.0.sha256 covered only the tarballs. Then per-architecture
414+
# builds arrived ending in "-arm64" and "-x86_64", which "*-macos" does not
415+
# match either, so v0.6.0 shipped without a hash for the two binaries most
416+
# people download. Listing everything cannot drift out of step with the names.
417+
find . -maxdepth 1 -type f ! -name "$CHECKSUMS_FILE" -exec shasum -a 256 {} \; > "$CHECKSUMS_FILE"
418+
419+
# And check the binaries by name, so a missing artifact fails here rather than
420+
# at somebody's `shasum -c`.
421+
MISSING=""
422+
for EXPECTED in \
423+
"usbipd-${{ needs.release-validation.outputs.version }}-macos" \
424+
"usbipd-${{ needs.release-validation.outputs.version }}-macos-arm64" \
425+
"usbipd-${{ needs.release-validation.outputs.version }}-macos-x86_64"; do
426+
if [ -f "$EXPECTED" ] && ! grep -q -- " ./$EXPECTED\$" "$CHECKSUMS_FILE"; then
427+
MISSING="$MISSING $EXPECTED"
428+
fi
429+
done
430+
if [ -n "$MISSING" ]; then
431+
echo "::error title=Checksums Incomplete::No checksum recorded for:$MISSING"
432+
exit 1
433+
fi
414434
415435
if ! grep -q -- "-macos" "$CHECKSUMS_FILE"; then
416436
echo "::error title=Checksums Incomplete::No binary checksum was recorded"

Sources/USBIPDCore/Device/USBDeviceCommunicatorImplementation.swift

Lines changed: 61 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -192,12 +192,14 @@ public class USBDeviceCommunicatorImplementation: USBDeviceCommunicator, @unchec
192192
logger.debug("Executing control transfer for device \(device.busID)-\(device.deviceID), endpoint \(request.endpoint)")
193193

194194
// Execute control transfer through IOKit interface
195-
return try await interface.executeControlTransfer(
195+
let result = try await interface.executeControlTransfer(
196196
endpoint: request.endpoint,
197197
setupPacket: request.setupPacket ?? Data(),
198198
transferBuffer: request.transferBuffer,
199199
timeout: request.timeout
200200
)
201+
discardInterfaceIfDeviceGone(result, device: device, interfaceNumber: 0)
202+
return result
201203
}
202204

203205
// Compose the USB endpoint address IOKit expects.
@@ -226,12 +228,14 @@ public class USBDeviceCommunicatorImplementation: USBDeviceCommunicator, @unchec
226228
logger.debug("Executing bulk transfer for device \(device.busID)-\(device.deviceID), endpoint \(request.endpoint)")
227229

228230
// Execute bulk transfer through IOKit interface
229-
return try await interface.executeBulkTransfer(
231+
let result = try await interface.executeBulkTransfer(
230232
endpoint: endpointAddress(for: request),
231233
data: request.transferBuffer,
232234
bufferLength: request.bufferLength,
233235
timeout: request.timeout
234236
)
237+
discardInterfaceIfDeviceGone(result, device: device, interfaceNumber: 0)
238+
return result
235239
}
236240

237241
public func executeInterruptTransfer(device: USBDevice, request: USBRequestBlock) async throws -> USBTransferResult {
@@ -245,12 +249,14 @@ public class USBDeviceCommunicatorImplementation: USBDeviceCommunicator, @unchec
245249
logger.debug("Executing interrupt transfer for device \(device.busID)-\(device.deviceID), endpoint \(request.endpoint)")
246250

247251
// Execute interrupt transfer through IOKit interface
248-
return try await interface.executeInterruptTransfer(
252+
let result = try await interface.executeInterruptTransfer(
249253
endpoint: endpointAddress(for: request),
250254
data: request.transferBuffer,
251255
bufferLength: request.bufferLength,
252256
timeout: request.timeout
253257
)
258+
discardInterfaceIfDeviceGone(result, device: device, interfaceNumber: 0)
259+
return result
254260
}
255261

256262
public func executeIsochronousTransfer(device: USBDevice, request: USBRequestBlock) async throws -> USBTransferResult {
@@ -264,13 +270,15 @@ public class USBDeviceCommunicatorImplementation: USBDeviceCommunicator, @unchec
264270
logger.debug("Executing isochronous transfer for device \(device.busID)-\(device.deviceID), endpoint \(request.endpoint)")
265271

266272
// Execute isochronous transfer through IOKit interface
267-
return try await interface.executeIsochronousTransfer(
273+
let result = try await interface.executeIsochronousTransfer(
268274
endpoint: endpointAddress(for: request),
269275
data: request.transferBuffer,
270276
bufferLength: request.bufferLength,
271277
startFrame: request.startFrame,
272278
numberOfPackets: max(request.numberOfPackets, 1)
273279
)
280+
discardInterfaceIfDeviceGone(result, device: device, interfaceNumber: 0)
281+
return result
274282
}
275283

276284
// MARK: - Helper Methods
@@ -354,6 +362,55 @@ public class USBDeviceCommunicatorImplementation: USBDeviceCommunicator, @unchec
354362
return interface
355363
}
356364

365+
/// Whether a transfer result means the cached interface can no longer be used.
366+
///
367+
/// `deviceGone` is what both `kIOReturnNoDevice` and `kIOReturnNotResponding` map
368+
/// to. Either way the IOKit interface behind the handle is finished, and every
369+
/// transfer through it will keep failing.
370+
static func shouldDiscardInterface(after status: USBStatus) -> Bool {
371+
return status == .deviceGone
372+
}
373+
374+
/// Drop a cached interface whose device has gone, so the next request opens a new
375+
/// one instead of reusing a handle that can only fail.
376+
///
377+
/// Interfaces are opened once and kept. That is right while a device stays put, and
378+
/// wrong the moment it does not: a device that disappears briefly — re-enumerating,
379+
/// or an Android phone changing its USB configuration — left the daemon holding a
380+
/// dead handle, and every subsequent transfer returned "no device" until the daemon
381+
/// was restarted. Observed with a Pixel, where a fresh daemon worked immediately
382+
/// while the running one never recovered.
383+
private func discardInterfaceIfDeviceGone(
384+
_ result: USBTransferResult,
385+
device: USBDevice,
386+
interfaceNumber: UInt8
387+
) {
388+
guard USBDeviceCommunicatorImplementation.shouldDiscardInterface(after: result.status) else {
389+
return
390+
}
391+
392+
let deviceKey = deviceIdentifier(for: device)
393+
394+
// Held only long enough to take the interface out of the table. Letting it
395+
// deallocate inside the lock would run its deinit — and so `close()`, and so
396+
// IOKit calls — while every other transfer waited on that lock.
397+
var discarded: IOKitUSBInterface?
398+
interfaceLock.lock()
399+
discarded = activeInterfaces[deviceKey]?.removeValue(forKey: interfaceNumber)
400+
if activeInterfaces[deviceKey]?.isEmpty == true {
401+
activeInterfaces.removeValue(forKey: deviceKey)
402+
}
403+
interfaceLock.unlock()
404+
405+
guard discarded != nil else { return }
406+
discarded = nil
407+
408+
logger.warning("Device reported gone; discarding the cached interface so it is reopened", context: [
409+
"device": deviceKey,
410+
"interface": String(interfaceNumber)
411+
])
412+
}
413+
357414
// MARK: - Transfer Cancellation
358415

359416
public func cancelAllTransfers(device: USBDevice, interfaceNumber: UInt8) async throws {
Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,50 @@
1+
// StaleInterfaceTests.swift
2+
// A cached interface must be dropped once its device is gone, and kept otherwise.
3+
4+
import XCTest
5+
@testable import USBIPDCore
6+
@testable import Common
7+
8+
final class StaleInterfaceTests: XCTestCase {
9+
10+
/// Interfaces are opened once and reused, which is right while a device stays put
11+
/// and wrong the moment it does not. A device that disappeared briefly left the
12+
/// daemon holding a dead IOKit handle, and every transfer through it failed with
13+
/// "no device" until the daemon was restarted — observed with a Pixel, where a fresh
14+
/// daemon worked immediately while the running one never recovered.
15+
func testDeviceGoneDiscardsTheInterface() {
16+
XCTAssertTrue(USBDeviceCommunicatorImplementation.shouldDiscardInterface(after: .deviceGone))
17+
}
18+
19+
/// The other half, and the more important one. Reopening on any old failure would
20+
/// throw away a perfectly good interface on every stall or timeout — and a timeout
21+
/// is the ordinary outcome of a read on an endpoint with nothing to say, which this
22+
/// project's own concurrency check produces on purpose.
23+
func testOrdinaryFailuresKeepTheInterface() {
24+
let keepers: [USBIPDCore.USBStatus] = [
25+
.success,
26+
.timeout,
27+
.cancelled,
28+
.stall,
29+
.shortPacket,
30+
.bufferError,
31+
.invalidRequest,
32+
.requestFailed
33+
]
34+
for status in keepers {
35+
XCTAssertFalse(
36+
USBDeviceCommunicatorImplementation.shouldDiscardInterface(after: status),
37+
"\(status) must not discard a usable interface")
38+
}
39+
}
40+
41+
/// Both IOKit codes that mean the device is finished map to the same status, so
42+
/// covering `deviceGone` covers `kIOReturnNoDevice` and `kIOReturnNotResponding`.
43+
func testBothIOKitDeviceGoneCodesMapToTheDiscardedStatus() {
44+
XCTAssertEqual(USBErrorMapping.mapIOKitError(kIOReturnNoDevice), USBStatus.deviceGone.rawValue)
45+
XCTAssertEqual(USBErrorMapping.mapIOKitError(kIOReturnNotResponding), USBStatus.deviceGone.rawValue)
46+
47+
// And a timeout does not, or the check above would be vacuous.
48+
XCTAssertNotEqual(USBErrorMapping.mapIOKitError(kIOReturnTimeout), USBStatus.deviceGone.rawValue)
49+
}
50+
}

0 commit comments

Comments
 (0)