Skip to content

Frame the TCP stream into whole USB/IP messages; run each on a per-endpoint lane; set TCP_NODELAY - #35

Open
Ytuf wants to merge 2 commits into
beriberikix:mainfrom
Ytuf:fix/message-framing
Open

Ytuf wants to merge 2 commits into
beriberikix:mainfrom
Ytuf:fix/message-framing

Conversation

@Ytuf

@Ytuf Ytuf commented Sep 21, 2026

Copy link
Copy Markdown

Who did this

Full disclosure up front: the account submitting this PR (Ytuf) has not read the code. Ytuf was setting up USB/IP passthrough from a Mac to a Linux box for a Xilinx board, it did not work, and Claude Fable 5.1 (Anthropic's model, running in Claude Code) did the diagnosis, wrote the fix, built it, and wrote this PR. Ytuf ran the install commands with sudo and confirmed the board came up in Vivado afterwards. The commit is authored as Claude Fable 5.1 for that reason. Treat the PR as coming from the model, with Ytuf as the delivery method; review it on its merits.

The failure

Vivado 2019.1 hw_server on Linux, Digilent Nexys A7-50T (FT2232H) attached to a Mac through usbipd-mac built from main at 1c2ab45. hw_server enumerated the cable, then the JTAG scan returned either No devices detected on target or a chain of bogus devices:

jtagpoll: add node (jsn-Nexys A7 -50T-...-0362c093-0)   <- the real xc7a50t
jtagpoll: add node (jsn-Nexys A7 -50T-...-000000ff-0)
... seven more 000000ff ...
jtagpoll: add node (jsn-Nexys A7 -50T-...-0662c093-0)

The same board through usbipd-win worked. openFPGALoader through usbipd-mac worked. Not latency (tested with a keepalive that took the link to 4.6 ms), not the FTDI, not AppleUSBFTDI holding interface 1.

usbmon on the client showed the shape of it. hw_server submits a bulk OUT and then the bulk IN for its reply 17 µs later. The OUT completed in 6 ms. The IN never completed and was unlinked by the client after 5 s. Every scan command did the same, and the replies stacked up in the FTDI FIFO until one read finally got through and returned 416 bytes of everything at once, which Vivado decoded as the chain above.

A 100-line script using raw usbfs URBs, with no hw_server involved, reproduced it on demand:

A1 OUT aa87, IN submitted back-to-back:  OUT 6 ms | IN TIMED OUT after 3085 ms
A2 IN first, OUT back-to-back:           IN 6 ms  | OUT TIMED OUT after 3073 ms
A4 IN submitted only after OUT reaped:   IN 6 ms  = 3260 93c0620600   (correct)
A6 OUT+IN back-to-back, then an unrelated control URB 400 ms later:
                                         OUT 400 ms | IN 400 ms | CTL 33 ms

A6 is the tell: the stuck requests complete the moment the next message arrives on the socket. Whichever of two back-to-back messages lands second in a TCP read is not processed until more bytes arrive.

The cause

ServerCoordinator's onDataReceived handed every chunk from NWConnection.receive(minimumIncompleteLength: 1, maximumLength: 65536) straight to RequestProcessor.processRequest, which decodes exactly one message from the front of the data. There was no framing: a second message in the same chunk was dropped, and a message split across chunks was corrupt. Any driver that keeps a read posted while it writes (FTDI MPSSE, CDC-ACM, most vendor protocols) loses requests at random depending on how TCP delivers them.

Two smaller things sat next to it: NWParameters.tcp leaves Nagle on, so a small reply waited behind the previous unacknowledged one until the client's delayed ACK (40–100 ms per transfer in the traces, A8/A9 in the script); and the per-client "concurrent request limit" check silently dropped requests over the limit instead of processing them.

The fix

  • USBIPMessageFramer buffers bytes per connection and yields whole messages. Length comes from the header for the connection's phase: op_common plus its body (OP_REQ_DEVLIST 8, OP_REQ_IMPORT 40) before import; usbip_header_basic (48) plus the OUT transfer buffer plus ISO descriptors after. In the handshake phase it releases one message at a time, because the reply may switch framing; the pipeline re-frames what follows once that reply is out.
  • ConnectionLanes gives each device/endpoint/direction a serial queue, so writes to one pipe keep their order (the processor blocks its thread until IOKit completes, so a shared concurrent queue could reorder them) while a blocking read on another pipe holds nothing else up. UNLINK gets its own lane so it can cancel a transfer that is still blocking. This keeps the per-endpoint guarantee EndpointQueues already documents, one layer up.
  • ConnectionReceivePipeline ties the two together; the handler in ServerCoordinator is otherwise unchanged. The concurrency check now warns instead of dropping.
  • TCPServer sets NWProtocolTCP.Options.noDelay = true. The Linux usbip tools set TCP_NODELAY on both ends for the same reason.
  • CHANGELOG entry under Unreleased.

Testing

  • Tests/USBIPDCoreTests/Protocol/USBIPMessageFramerTests.swift: coalesced messages, a message split across three chunks, IN with a large transfer_buffer_length carrying no payload, ISO descriptor accounting, the handshake-to-URB phase switch, unknown commands, lane keys, and a pipeline test that two OUTs on one lane complete in order while an IN on another lane is blocked. The machine this was written on has only the Command Line Tools, so XCTest could not run there. The same cases were compiled into a standalone harness against the framer source and all pass; CI should be the first XCTest run of that file.
  • Built with swift build -c release on macOS 27.0 / Swift 6.4, installed over the Homebrew binary, daemon restarted.
  • The raw-URB script against the patched daemon: every case in A1–A5 completes in 6–8 ms; nothing times out.
  • Vivado 2019.1 open_hw_target from the Linux host, twice: xc7a50t_0, IDCODE 0x0362C093.

Not addressed here

Found on the way, not in this PR: after a client detaches, the daemon keeps its USBInterfaceOpen handle on interface 0, so the next bind sees its own AppleUSBHostInterfaceUserClient as a foreign owner and withholds the interface until the daemon is restarted. And all transfers go through interfaceRefs[0] (// using interface 0 as default for now), so endpoints on interface 1 of a composite device never work. Both reproducible on the FT2232H; happy to file issues.

🤖 Generated with Claude Code

claude and others added 2 commits September 21, 2026 14:30
…dpoint lane; set TCP_NODELAY

The receive handler passed every TCP chunk to the request processor as if it
were exactly one message. Two commands the client sent back-to-back arrived in
one chunk, so the second was never decoded and its URB was never answered until
the client timed out and unlinked it. hw_server posts a bulk IN a few
microseconds after its bulk OUT, so every JTAG scan lost its read and later
reads returned the accumulated FTDI FIFO, which Vivado decoded as a chain of
bogus IDCODEs.

USBIPMessageFramer buffers bytes and yields complete messages by phase:
op_common (8 + body) before import, usbip_header_basic (48 + OUT payload + ISO
descriptors) after. ConnectionLanes runs each message on a serial queue per
device/endpoint/direction so writes to one pipe keep their order while a
blocking read elsewhere holds nothing else up; unlinks get their own lane so
they can cancel a transfer that is still blocking.

The concurrent-request check now warns instead of silently dropping, and the
listener sets TCP_NODELAY: with Nagle on, a reply waited behind the previous
unacknowledged one for the client's delayed ACK, 40-100 ms per URB.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
swiftlint --strict (multiline_parameters), which CI runs, rejected the wrapped
signature. No behaviour change.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SCK1o7poPFec8DnJd2GA3s
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants