Skip to content

Commit 43046d8

Browse files
beriberikixclaude
andcommitted
Give each connection its own sequence-number space
USB/IP numbers requests per connection, and the Linux client opens one connection per attached device, so every client starts again at 1. Request tracking used a single process-wide table keyed on the bare sequence number, so a second client's first request looked like a duplicate of the first client's and was refused. Attaching two devices from one machine was enough: each attach is its own connection, each starts at seqnum 1. The refusal was silent. The duplicate check threw from outside the block that turns a rejected request into a RET_SUBMIT carrying the error, so the exception reached ServerCoordinator, which logged it and sent nothing. The client sat waiting for a reply that was never coming, rather than being told its request had been refused. Requests are now keyed by devid and sequence number together. A connection carries exactly one imported device, so devid distinguishes clients. The claim also moved inside the error-response block, so a genuine duplicate — same device, same number, still outstanding — is answered rather than met with silence. Both faults predate this branch: the previous code applied the identical guard to the identical process-wide dictionary from the identical position relative to the error handler. Found by running two clients against two devices at once, which had never been tried. Five of ten concurrent transfers failed before the change and none after; eighteen transfers across three devices now pass. The FTDI and the CP2102N were the pair, which is also the first exercise of the IOUserSerial ownership path since the classifier changed. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_017rmyikdjWveP99ZUCDLY89
1 parent 1eab5b6 commit 43046d8

3 files changed

Lines changed: 140 additions & 54 deletions

File tree

Sources/USBIPDCore/Protocol/USBSubmitProcessor.swift

Lines changed: 69 additions & 47 deletions
Original file line numberDiff line numberDiff line change
@@ -25,16 +25,29 @@ public class USBSubmitProcessor {
2525
let device: USBDevice?
2626
}
2727

28-
private var activeURBs: [UInt32: TrackedURB] = [:]
28+
/// Identifies a request. A sequence number alone does not.
29+
///
30+
/// USB/IP numbers requests per connection, and the Linux client opens one connection
31+
/// per attached device, so every client starts again at 1. Tracking by bare sequence
32+
/// number put all of them in one namespace: a second client's first request looked
33+
/// like a duplicate of the first client's, and attaching two devices from a single
34+
/// machine was enough to trigger it. Devid distinguishes them, since a connection
35+
/// carries exactly one imported device.
36+
private struct URBKey: Hashable {
37+
let devid: UInt32
38+
let seqnum: UInt32
39+
}
40+
41+
private var activeURBs: [URBKey: TrackedURB] = [:]
2942

30-
/// Sequence numbers accepted and not yet answered, claimed before the URB exists so
31-
/// that an UNLINK arriving during setup has something to find.
32-
private var reservedSeqnums: Set<UInt32> = []
43+
/// Requests accepted and not yet answered, claimed before the URB exists so that an
44+
/// UNLINK arriving during setup has something to find.
45+
private var reserved: Set<URBKey> = []
3346

34-
/// Sequence numbers the client withdrew. A transfer already in IOKit's hands may
35-
/// still complete after the abort, and its result must be dropped rather than sent:
36-
/// the client has been told the request is dead and is not expecting a reply.
37-
private var cancelledSeqnums: Set<UInt32> = []
47+
/// Requests the client withdrew. A transfer already in IOKit's hands may still
48+
/// complete after the abort, and its result must be dropped rather than sent: the
49+
/// client has been told the request is dead and is not expecting a reply.
50+
private var cancelled: Set<URBKey> = []
3851
private let urbQueue = DispatchQueue(label: "com.usbipd.mac.urb", attributes: .concurrent)
3952

4053
/// Device communicator for executing USB transfers
@@ -106,6 +119,20 @@ public class USBSubmitProcessor {
106119
do {
107120
try validateSubmitRequest(request)
108121
try await checkConcurrentRequestLimit(devid: request.devid)
122+
123+
// Claim the request before doing anything that can block.
124+
//
125+
// Registration used to happen after the device had been resolved, which
126+
// means after an IOKit enumeration. A client that submits a read and cancels
127+
// it a millisecond later — probe-rs drains the IN endpoint exactly that way
128+
// before its first command — sent the UNLINK into that window, found no such
129+
// URB, and was told the request had already completed. It had not: it went
130+
// on to run, and later returned data for a request the client had abandoned.
131+
//
132+
// This sits inside the block above so that a rejected claim is answered.
133+
// Thrown from outside it, a duplicate reached ServerCoordinator, which logs
134+
// and sends nothing, and the client waited for a reply that never came.
135+
try await reserveSequenceNumber(devid: request.devid, seqnum: request.seqnum)
109136
} catch {
110137
logger.warning("Rejected SUBMIT request", context: [
111138
"seqnum": String(request.seqnum),
@@ -126,19 +153,6 @@ public class USBSubmitProcessor {
126153
)
127154
}
128155

129-
// Claim the sequence number before doing anything that can block.
130-
//
131-
// Registration used to happen after the device had been resolved, which means
132-
// after an IOKit enumeration. A client that submits a read and cancels it a
133-
// millisecond later — probe-rs drains the IN endpoint exactly that way before
134-
// its first command — sent the UNLINK into that window, found no such URB, and
135-
// was told the request had already completed. It had not: it went on to run,
136-
// and later returned data for a request the client had abandoned.
137-
//
138-
// Only the sequence number is known this early, which is all the cancel path
139-
// needs to reserve the slot; the full URB replaces this entry below.
140-
try await reserveSequenceNumber(request.seqnum)
141-
142156
// Prefer the device's own description of the endpoint. CMD_SUBMIT has no
143157
// transfer-type field, so anything derived from the request alone is a guess —
144158
// and the interval-based guess below misreads every bulk endpoint that
@@ -194,17 +208,17 @@ public class USBSubmitProcessor {
194208
// The data is dropped with it. That is the honest outcome: it belongs to a
195209
// transfer the client cancelled, and handing it to the next read would put
196210
// one command's answer in front of another's.
197-
if await wasCancelled(request.seqnum) {
211+
if await wasCancelled(devid: request.devid, seqnum: request.seqnum) {
198212
logger.info("Discarding result of a cancelled request", context: [
199213
"seqnum": String(request.seqnum),
200214
"actualLength": String(result.actualLength)
201215
])
202-
await removeActiveURB(request.seqnum)
216+
await removeActiveURB(devid: request.devid, seqnum: request.seqnum)
203217
return Data()
204218
}
205219

206220
// Remove URB from tracking
207-
await removeActiveURB(request.seqnum)
221+
await removeActiveURB(devid: request.devid, seqnum: request.seqnum)
208222

209223
return try USBIPMessageEncoder.encodeUSBSubmitResponse(
210224
seqnum: response.seqnum,
@@ -221,11 +235,11 @@ public class USBSubmitProcessor {
221235
} catch {
222236
// Aborting a pipe makes the transfer in flight fail, so a cancelled request
223237
// usually arrives here rather than above. It is silent for the same reason.
224-
if await wasCancelled(request.seqnum) {
238+
if await wasCancelled(devid: request.devid, seqnum: request.seqnum) {
225239
logger.info("Cancelled request ended without a reply", context: [
226240
"seqnum": String(request.seqnum)
227241
])
228-
await removeActiveURB(request.seqnum)
242+
await removeActiveURB(devid: request.devid, seqnum: request.seqnum)
229243
return Data()
230244
}
231245

@@ -238,7 +252,7 @@ public class USBSubmitProcessor {
238252
let errorResponse = createErrorResponse(from: request, error: error)
239253

240254
// Remove URB from tracking
241-
await removeActiveURB(request.seqnum)
255+
await removeActiveURB(devid: request.devid, seqnum: request.seqnum)
242256

243257
return try USBIPMessageEncoder.encodeUSBSubmitResponse(
244258
seqnum: errorResponse.seqnum,
@@ -324,12 +338,13 @@ public class USBSubmitProcessor {
324338
/// carries the duplicate check that used to live in `addActiveURB`, which is the
325339
/// right place for it: a repeat of a sequence number is a client error whether or
326340
/// not the first one has finished being prepared.
327-
private func reserveSequenceNumber(_ seqnum: UInt32) async throws {
341+
private func reserveSequenceNumber(devid: UInt32, seqnum: UInt32) async throws {
328342
try urbQueue.sync(flags: .barrier) {
329-
guard !reservedSeqnums.contains(seqnum) else {
343+
let key = URBKey(devid: devid, seqnum: seqnum)
344+
guard !reserved.contains(key) else {
330345
throw USBRequestError.duplicateRequest
331346
}
332-
reservedSeqnums.insert(seqnum)
347+
reserved.insert(key)
333348
}
334349
}
335350

@@ -338,34 +353,37 @@ public class USBSubmitProcessor {
338353
// Barrier: this mutates shared state on a concurrent queue. Plain .sync
339354
// lets writers run simultaneously and corrupts the dictionary.
340355
urbQueue.sync(flags: .barrier) {
341-
activeURBs[urb.seqnum] = TrackedURB(urb: urb, status: .pending, device: device)
356+
activeURBs[URBKey(devid: urb.devid, seqnum: urb.seqnum)] =
357+
TrackedURB(urb: urb, status: .pending, device: device)
342358
}
343359
}
344360

345361
/// Whether the client withdrew this request while it was running.
346-
func wasCancelled(_ seqnum: UInt32) async -> Bool {
347-
return urbQueue.sync { cancelledSeqnums.contains(seqnum) }
362+
func wasCancelled(devid: UInt32, seqnum: UInt32) async -> Bool {
363+
return urbQueue.sync { cancelled.contains(URBKey(devid: devid, seqnum: seqnum)) }
348364
}
349365

350366
/// Remove URB from active tracking
351-
private func removeActiveURB(_ seqnum: UInt32) async {
367+
private func removeActiveURB(devid: UInt32, seqnum: UInt32) async {
352368
// Barrier: this mutates shared state on a concurrent queue. Plain .sync
353369
// lets writers run simultaneously and corrupts the dictionary.
354370
urbQueue.sync(flags: .barrier) {
355-
activeURBs.removeValue(forKey: seqnum)
356-
reservedSeqnums.remove(seqnum)
357-
cancelledSeqnums.remove(seqnum)
371+
let key = URBKey(devid: devid, seqnum: seqnum)
372+
activeURBs.removeValue(forKey: key)
373+
reserved.remove(key)
374+
cancelled.remove(key)
358375
}
359376
}
360377

361378
/// Update URB status
362-
private func updateURBStatus(_ seqnum: UInt32, status: URBStatus) async {
379+
private func updateURBStatus(devid: UInt32, seqnum: UInt32, status: URBStatus) async {
363380
// Barrier: this mutates shared state on a concurrent queue. Plain .sync
364381
// lets writers run simultaneously and corrupts the dictionary.
365382
urbQueue.sync(flags: .barrier) {
366-
if var entry = activeURBs[seqnum] {
383+
let key = URBKey(devid: devid, seqnum: seqnum)
384+
if var entry = activeURBs[key] {
367385
entry.status = status
368-
activeURBs[seqnum] = entry
386+
activeURBs[key] = entry
369387
}
370388
}
371389
}
@@ -377,7 +395,7 @@ public class USBSubmitProcessor {
377395
}
378396

379397
// Update URB status
380-
await updateURBStatus(urb.seqnum, status: .inProgress)
398+
await updateURBStatus(devid: urb.devid, seqnum: urb.seqnum, status: .inProgress)
381399

382400
// Execute based on transfer type
383401
switch urb.transferType {
@@ -567,23 +585,27 @@ public class USBSubmitProcessor {
567585
return urbQueue.sync { activeURBs.count }
568586
}
569587

570-
/// Cancel URB by sequence number (for UNLINK support)
571-
public func cancelURB(_ seqnum: UInt32) async -> Bool {
588+
/// Cancel a request (for UNLINK support).
589+
///
590+
/// Takes the devid as well as the sequence number: numbering restarts per
591+
/// connection, so a sequence number on its own could name another client's request.
592+
public func cancelURB(devid: UInt32, seqnum: UInt32) async -> Bool {
572593
// Barrier: this mutates shared state on a concurrent queue. Plain .sync
573594
// lets writers run simultaneously and corrupts the dictionary.
574595
let claimed: CancelClaim = urbQueue.sync(flags: .barrier) {
575596
// A reservation is enough. Requiring an entry in activeURBs meant a request
576597
// still being set up could not be cancelled, and the client was told its URB
577598
// had already completed when in fact it had not started.
578-
guard reservedSeqnums.contains(seqnum) else {
599+
let key = URBKey(devid: devid, seqnum: seqnum)
600+
guard reserved.contains(key) else {
579601
return CancelClaim(reserved: false, urb: nil, device: nil)
580602
}
581-
cancelledSeqnums.insert(seqnum)
582-
guard var entry = activeURBs[seqnum] else {
603+
cancelled.insert(key)
604+
guard var entry = activeURBs[key] else {
583605
return CancelClaim(reserved: true, urb: nil, device: nil)
584606
}
585607
entry.status = .cancelled
586-
activeURBs[seqnum] = entry
608+
activeURBs[key] = entry
587609
return CancelClaim(reserved: true, urb: entry.urb, device: entry.device)
588610
}
589611

Sources/USBIPDCore/Protocol/USBUnlinkProcessor.swift

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -165,7 +165,7 @@ public class USBUnlinkProcessor {
165165
])
166166

167167
// Attempt to cancel the URB at the protocol level first
168-
let urbCancelSuccess = await processor.cancelURB(request.unlinkSeqnum)
168+
let urbCancelSuccess = await processor.cancelURB(devid: request.devid, seqnum: request.unlinkSeqnum)
169169

170170
// Aborting the pipe belongs to the submit processor, which is the only place
171171
// that knows what a sequence number was submitted against.

Tests/USBIPDCoreTests/Protocol/USBRequestProcessorTests.swift

Lines changed: 70 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -432,7 +432,7 @@ final class USBRequestProcessorTests: XCTestCase {
432432
var didCancel = false
433433
for _ in 0..<100 where !didCancel {
434434
try await Task.sleep(nanoseconds: 5_000_000)
435-
didCancel = await submitProcessor.cancelURB(seqnum)
435+
didCancel = await submitProcessor.cancelURB(devid: USBRequestProcessorTests.testDevid, seqnum: seqnum)
436436
}
437437
XCTAssertTrue(didCancel, "a request that is still running should be cancellable")
438438

@@ -448,7 +448,7 @@ final class USBRequestProcessorTests: XCTestCase {
448448
}
449449

450450
func testUnknownSequenceNumberIsNotCancellable() async throws {
451-
let cancelled = await submitProcessor.cancelURB(999_999)
451+
let cancelled = await submitProcessor.cancelURB(devid: USBRequestProcessorTests.testDevid, seqnum: 999_999)
452452
XCTAssertFalse(cancelled, "a sequence number never submitted should not be cancellable")
453453
}
454454

@@ -704,21 +704,85 @@ final class USBRequestProcessorTests: XCTestCase {
704704
XCTAssertEqual(finalResponse.status, 0)
705705
XCTAssertEqual(finalResponse.actualLength, 16)
706706
}
707+
708+
// MARK: - Two clients do not share a sequence-number space
709+
710+
/// USB/IP numbers requests per connection, and the Linux client opens one connection
711+
/// per attached device — so every client starts again at 1. Tracking by bare
712+
/// sequence number put them all in one namespace, and a second client's first
713+
/// request was rejected as a duplicate of the first client's. Attaching two devices
714+
/// from a single machine was enough to trigger it: five of ten concurrent transfers
715+
/// failed against real hardware.
716+
func testTwoDevicesMayUseTheSameSequenceNumber() async throws {
717+
// Two attached devices, so both devids resolve to something real.
718+
let other = USBDevice(
719+
busID: "2", deviceID: "3",
720+
vendorID: 0x4321, productID: 0x8765,
721+
deviceClass: 0x00, deviceSubClass: 0x00, deviceProtocol: 0x00,
722+
speed: .high,
723+
manufacturerString: nil, productString: nil, serialNumberString: nil
724+
)
725+
let processor = USBSubmitProcessor(
726+
deviceCommunicator: mockDeviceCommunicator,
727+
deviceDiscovery: StubDeviceDiscovery(devices: [testDevice, other])
728+
)
729+
730+
let first = USBRequestProcessorTests.testDevid
731+
let second = (2 << 16) | UInt32(0x3)
732+
733+
let firstRequest = try createUSBSubmitRequestData(seqnum: 1, devid: first, endpoint: 0x00)
734+
let secondRequest = try createUSBSubmitRequestData(seqnum: 1, devid: second, endpoint: 0x00)
735+
736+
let firstResponse = try USBIPSubmitResponse.decode(
737+
from: try await processor.processSubmitRequest(firstRequest))
738+
let secondResponse = try USBIPSubmitResponse.decode(
739+
from: try await processor.processSubmitRequest(secondRequest))
740+
741+
XCTAssertEqual(firstResponse.status, 0)
742+
XCTAssertEqual(secondResponse.status, 0, "a second device reusing seqnum 1 must not look like a duplicate")
743+
}
744+
745+
/// A genuine duplicate — same device, same sequence number, still outstanding — is
746+
/// still refused. And it is refused with a reply: the rejection used to be thrown
747+
/// past the error-response path, so ServerCoordinator logged it, sent nothing, and
748+
/// the client waited forever for an answer that was never coming.
749+
func testDuplicateOnOneDeviceIsRefusedWithAReply() async throws {
750+
mockDeviceCommunicator.setOperationLatency(300)
751+
752+
let requestData = try createUSBSubmitRequestData(seqnum: 77, endpoint: 0x00)
753+
let inFlight = Task { try await self.submitProcessor.processSubmitRequest(requestData) }
754+
755+
// Let the first request claim the sequence number before repeating it.
756+
try await Task.sleep(nanoseconds: 50_000_000)
757+
758+
let duplicate = try await submitProcessor.processSubmitRequest(requestData)
759+
XCTAssertFalse(duplicate.isEmpty, "a duplicate must be answered, not met with silence")
760+
761+
let response = try USBIPSubmitResponse.decode(from: duplicate)
762+
XCTAssertEqual(response.seqnum, 77)
763+
XCTAssertNotEqual(response.status, 0, "a duplicate must report an error")
764+
765+
_ = try await inFlight.value
766+
}
707767
}
708768

709-
/// Returns a single device for any lookup, so submitted URBs resolve to test hardware.
710769
private final class StubDeviceDiscovery: DeviceDiscovery {
711-
private let device: USBDevice
770+
private let devices: [USBDevice]
771+
private var device: USBDevice { devices[0] }
712772

713773
var onDeviceConnected: ((USBDevice) -> Void)?
714774
var onDeviceDisconnected: ((USBDevice) -> Void)?
715775

716776
init(device: USBDevice) {
717-
self.device = device
777+
self.devices = [device]
778+
}
779+
780+
init(devices: [USBDevice]) {
781+
self.devices = devices
718782
}
719783

720784
func discoverDevices() throws -> [USBDevice] {
721-
return [device]
785+
return devices
722786
}
723787

724788
func getDevice(busID: String, deviceID: String) throws -> USBDevice? {

0 commit comments

Comments
 (0)