usbip: drain controller callbacks and centralize ret_submit validation

P1: The IOUSBHostControllerInterface command/doorbell blocks ran on an
Apple-owned dispatch queue, so callbacks could still fire (and panic in
cgo.Handle.Value) after Close deleted the handle. The controller now owns
a serial dispatch queue and drains it via dispatch_sync before tearing
the wrapper down — the watcher's pattern, extracted into a shared
box_usbhost_drain_and_release_queue helper.

P2: scatterResponse only bounds-checked ActualLength on IN transfers, so
an OUT RET_SUBMIT with a negative or oversized actual_length flowed
straight to box_usbhost_endpoint_sm_complete as a wrapped C.size_t.
Validation moves into pendingTransfer.accept, which reconciles every
response against the request before the length leaves the package.

Also wraps cgo.Handle in cgoCallbackHandle so destroy-then-Delete
ordering is encoded structurally for both the watcher and the controller.
This commit is contained in:
世界
2026-05-16 14:34:28 +08:00
parent 9c8147f234
commit 4db2957a49
4 changed files with 116 additions and 133 deletions
-89
View File
@@ -1,89 +0,0 @@
# sing-box-usbip domain glossary
This file names the load-bearing concepts in the USBIP subsystem so design
discussions, ADRs, and architecture reviews share vocabulary. Concepts here
should be reused verbatim in code identifiers and comments.
## USBIP subsystem
**USBIP** — a wire protocol that exports a USB device over TCP. sing-box
implements both server (export) and client (import) roles. Linux uses
kernel `usbip-host` / `vhci_hcd`; Darwin uses an IOUSBHost capture.
**Bus ID** (`busid`) — string identifying a USB device location on the host
(e.g. `1-1.4`). The unit of admission control: the ledger reserves a busid,
not a device handle.
**Export** — a host-owned handle to a single locally attached device that
the server publishes to control subscribers and hands to an import
session. The `Export` interface (`service/usbip/host.go`) is the seam
between platform-specific host code and the platform-neutral ledger.
Once published into a reconcile snapshot, an Export pointer is treated
as immutable (see ADR-0001).
**Export Host** (`ExportHost`) — the platform implementation that
discovers candidate devices, owns the OS-level capture (sysfs bind on
Linux, IOKit open on Darwin), and produces the Export map via
`Reconcile`. One `ExportHost` per server.
**Import Host** (`ImportHost`) — the symmetric platform implementation
on the client side; takes a wire conn and attaches it to the local
USB stack (Linux: `vhci_hcd` attach; Darwin: stub).
**Export Ledger** (`exportLedger` in `service/usbip/export_ledger.go`) —
the per-server admission, lease, and broadcast authority. Owns three
pieces of state under one mutex (the "inventory" lock):
- `exports` — published Export pointers, keyed by busid.
- `busy` — busids with an active import session.
- `leases` — short-lived holds that block admission until the holder
consumes them or the TTL expires.
Plus broadcast bookkeeping under a separate "fast" lock (sequence,
subscribers, last-broadcast state).
**Reserved State** — the union of busy and unexpired-lease entries for
a given busid. `reservedLocked(busid)` returns this. Every change to
reserved state MUST broadcast a control-frame delta to subscribers —
the `mutateAndBroadcast` / `withInventoryWrite` accessor enforces this
structurally.
**Lease** — a control-channel reservation (`LEASE_REQ``IMPORT_EXT`)
that pins a device for one extended client. Identified by `(lease ID,
client nonce)`; pinned to the export's `LeaseIdentity` (registry ID on
Darwin, sysfs identity on Linux) so reconcile-driven swaps invalidate
stale leases.
**Reconcile** — the host operation that diffs the desired set of
matched devices against the currently exported set and produces a
plan (`toAdd`, `toRemove`, `toStale`). Stale entries remain in the
published snapshot (with `state: unavailable`) so subscribers see a
transition instead of a silent removal.
**Stale Export** — an Export the host wanted to drop while it was still
reserved. Stays owned by the host until the holding import session
ends; surfaces to subscribers as `state: unavailable`. Linux marks the
clone; Darwin marks the clone (after ADR-0001).
**Control Subscriber** — a client connected to the control channel
receiving `controlFrameDeviceSnapshot` and `controlFrameDeviceDelta`
frames. "Extended" subscribers (`supportsControlExtensions`) get
deltas; legacy subscribers get `controlFrameChanged` and re-fetch.
## Platform conventions
**Linux Export** (`linuxExport`) — wraps a sysfs `usbip-host` binding.
Identity is the cached descriptor + identity slice; published pointers
are immutable, cloned via `cloneLinuxExport` before stale-marking.
**Darwin Export** (`darwinExport`) — wraps an IOUSBHost capture
(`darwinUSBHostDevice`). Identity is the IOKit `registryID`; published
pointers are immutable, cloned via `cloneDarwinExport` before
stale-marking.
## Decision records
Architecture decisions live in `docs/adr/`. Active records:
- ADR-0001 — Export pointer immutability (the rule that lets the ledger
read `Snapshot`/`LeaseCheck` outside any lock).
+32 -16
View File
@@ -8,10 +8,16 @@ import (
"unsafe"
"github.com/sagernet/sing-box/log"
E "github.com/sagernet/sing/common/exceptions"
"golang.org/x/sys/unix"
)
var (
errResponseNegativeActualLength = E.New("RET_SUBMIT actual_length is negative")
errResponseOverflow = E.New("RET_SUBMIT actual_length exceeds request length")
)
type darwinEndpoint struct {
ctx context.Context
cancel context.CancelFunc
@@ -184,7 +190,12 @@ func (e *darwinEndpoint) finalizePending(pending *pendingTransfer) {
status = -int32(unix.EIO)
length = 0
default:
status, length = e.scatterResponse(pending, response)
var acceptErr error
status, length, acceptErr = pending.accept(response)
if acceptErr != nil {
e.logger.Debug("RET_SUBMIT validation: ", acceptErr, " (request length ", pending.requestLen, ")")
e.cancel()
}
}
if pending.noResponse {
return
@@ -196,31 +207,36 @@ func (e *darwinEndpoint) finalizePending(pending *pendingTransfer) {
}
}
func (e *darwinEndpoint) scatterResponse(pending *pendingTransfer, response SubmitResponse) (int32, int) {
if pending.direction != USBIPDirIn {
return response.Status, int(response.ActualLength)
}
// accept validates a RET_SUBMIT against the original request and, for IN
// transfers, scatters the payload into the Apple-owned buffer. The returned
// length is guaranteed to be in [0, p.requestLen] regardless of direction.
// A non-nil err signals a protocol violation; the caller is expected to
// cancel the endpoint so no further wire-corrupt completions are delivered
// to IOUSBHost.
func (p *pendingTransfer) accept(response SubmitResponse) (int32, int, error) {
if response.ActualLength < 0 {
e.logger.Debug("RET_SUBMIT actual_length is negative: ", response.ActualLength)
e.cancel()
return -int32(unix.EPROTO), 0
return -int32(unix.EPROTO), 0, errResponseNegativeActualLength
}
actualLength := int(response.ActualLength)
if actualLength > pending.requestLen || len(response.Buffer) > pending.requestLen {
e.logger.Debug("RET_SUBMIT actual_length ", actualLength, " exceeds request length ", pending.requestLen)
e.cancel()
return -int32(unix.EOVERFLOW), 0
if actualLength > p.requestLen {
return -int32(unix.EOVERFLOW), 0, errResponseOverflow
}
if p.direction != USBIPDirIn {
return response.Status, actualLength, nil
}
if len(response.Buffer) > p.requestLen {
return -int32(unix.EOVERFLOW), 0, errResponseOverflow
}
copyLength := min(actualLength, len(response.Buffer))
if copyLength > 0 && pending.bufferPtr != nil {
if copyLength > 0 && p.bufferPtr != nil {
if len(response.IsoPackets) > 0 {
dst := unsafe.Slice((*byte)(pending.bufferPtr), pending.requestLen)
dst := unsafe.Slice((*byte)(p.bufferPtr), p.requestLen)
ScatterIsoResponse(dst, response.Buffer[:copyLength], response.IsoPackets)
} else {
copy(unsafe.Slice((*byte)(pending.bufferPtr), copyLength), response.Buffer[:copyLength])
copy(unsafe.Slice((*byte)(p.bufferPtr), copyLength), response.Buffer[:copyLength])
}
}
return response.Status, actualLength
return response.Status, actualLength, nil
}
func (e *darwinEndpoint) startTransfer(transfer darwinCITransfer, noResponse bool) *pendingTransfer {
+50 -18
View File
@@ -75,9 +75,39 @@ type darwinCITransfer struct {
message darwinCIMessage
}
// cgoCallbackHandle pairs a cgo.Handle whose value is invoked asynchronously
// from C with the ordering rule that the handle MUST NOT be deleted until the
// C-side producer has been destroyed AND any in-flight callbacks have been
// drained. closeAfter encodes that ordering: the destroyC callback runs first,
// and only on its return is the handle deleted. destroyC MUST NOT return
// until the C-side serial dispatch queue has been synchronously drained (see
// box_usbhost_drain_and_release_queue in usbhost_darwin.m).
type cgoCallbackHandle struct {
handle cgo.Handle
}
func newCgoCallbackHandle(value any) cgoCallbackHandle {
return cgoCallbackHandle{handle: cgo.NewHandle(value)}
}
func (c cgoCallbackHandle) token() C.uintptr_t {
return C.uintptr_t(c.handle)
}
// deleteRaw releases the handle without waiting for any C-side drain. Use only
// to roll back a failed C-side create; otherwise prefer closeAfter.
func (c cgoCallbackHandle) deleteRaw() {
c.handle.Delete()
}
func (c cgoCallbackHandle) closeAfter(destroyC func()) {
destroyC()
c.handle.Delete()
}
type darwinUSBHostController struct {
handle *C.box_usbhost_controller_t
ref cgo.Handle
handle *C.box_usbhost_controller_t
callback cgoCallbackHandle
}
type darwinUSBHostDeviceSM struct {
@@ -131,48 +161,50 @@ func darwinOpenUSBHostDevice(registryID uint64, capture bool) (*darwinUSBHostDev
}
type darwinUSBHostDeviceWatcher struct {
handle *C.box_usbhost_device_watcher_t
ref cgo.Handle
handle *C.box_usbhost_device_watcher_t
callback cgoCallbackHandle
}
func darwinWatchUSBHostDevices(callback func()) (*darwinUSBHostDeviceWatcher, error) {
ref := cgo.NewHandle(callback)
ref := newCgoCallbackHandle(callback)
var errorPtr *C.char
handle := C.box_usbhost_device_watcher_create(C.uintptr_t(ref), &errorPtr)
handle := C.box_usbhost_device_watcher_create(ref.token(), &errorPtr)
if handle == nil {
ref.Delete()
ref.deleteRaw()
return nil, darwinCError(errorPtr)
}
return &darwinUSBHostDeviceWatcher{handle: handle, ref: ref}, nil
return &darwinUSBHostDeviceWatcher{handle: handle, callback: ref}, nil
}
func (w *darwinUSBHostDeviceWatcher) Close() {
if w == nil || w.handle == nil {
return
}
C.box_usbhost_device_watcher_destroy(w.handle)
w.handle = nil
w.ref.Delete()
w.callback.closeAfter(func() {
C.box_usbhost_device_watcher_destroy(w.handle)
w.handle = nil
})
}
func darwinCreateUSBHostController(controller *darwinVirtualController, portCount uint8, speed uint32) (*darwinUSBHostController, error) {
ref := cgo.NewHandle(controller)
ref := newCgoCallbackHandle(controller)
var errorPtr *C.char
handle := C.box_usbhost_controller_create(C.uintptr_t(ref), C.uint8_t(portCount), C.uint32_t(speed), &errorPtr)
handle := C.box_usbhost_controller_create(ref.token(), C.uint8_t(portCount), C.uint32_t(speed), &errorPtr)
if handle == nil {
ref.Delete()
ref.deleteRaw()
return nil, darwinCError(errorPtr)
}
return &darwinUSBHostController{handle: handle, ref: ref}, nil
return &darwinUSBHostController{handle: handle, callback: ref}, nil
}
func (c *darwinUSBHostController) Close() {
if c == nil || c.handle == nil {
return
}
C.box_usbhost_controller_destroy(c.handle)
c.handle = nil
c.ref.Delete()
c.callback.closeAfter(func() {
C.box_usbhost_controller_destroy(c.handle)
c.handle = nil
})
}
func (c *darwinUSBHostController) respond(message darwinCIMessage, status int) error {
+34 -10
View File
@@ -55,6 +55,7 @@ struct box_usbhost_device_watcher {
struct box_usbhost_controller {
void *object;
void *queue;
};
struct box_usbhost_device_sm {
@@ -102,6 +103,24 @@ void box_usbhost_free_error(char *error) {
free(error);
}
// box_usbhost_drain_and_release_queue empties any blocks already enqueued or
// executing on the serial dispatch queue referenced by *queue_slot and releases
// the CFBridgingRetain reference. The caller MUST first stop the producer of
// new callbacks (IONotificationPortDestroy, [IOUSBHostControllerInterface
// destroy], ...) so the drain converges; otherwise new blocks could keep
// arriving. After this returns, the Go-side cgo.Handle paired with the
// callbacks is safe to Delete.
static void box_usbhost_drain_and_release_queue(void **queue_slot) {
if (queue_slot == NULL || *queue_slot == NULL) {
return;
}
dispatch_queue_t queue = (__bridge dispatch_queue_t)*queue_slot;
dispatch_sync(queue, ^{
});
CFRelease(*queue_slot);
*queue_slot = NULL;
}
static CFMutableDictionaryRef box_usbhost_device_matching_dictionary(void) {
return [IOUSBHostDevice createMatchingDictionaryWithVendorID:nil
productID:nil
@@ -537,15 +556,7 @@ void box_usbhost_device_watcher_destroy(box_usbhost_device_watcher_t *watcher) {
IONotificationPortDestroy(watcher->port);
watcher->port = NULL;
}
if (watcher->queue != NULL) {
// Drain pending IOKit notification callbacks before releasing the
// cgo.Handle: the serial queue guarantees any block already
// enqueued (or executing) finishes before this empty block runs.
dispatch_queue_t queue = (__bridge dispatch_queue_t)watcher->queue;
dispatch_sync(queue, ^{});
CFRelease(watcher->queue);
watcher->queue = NULL;
}
box_usbhost_drain_and_release_queue(&watcher->queue);
free(watcher);
}
@@ -794,9 +805,10 @@ box_usbhost_controller_t *box_usbhost_controller_create(uintptr_t ref, uint8_t p
box_usbip_darwin_controller_doorbell(ref, doorbells[i]);
}
};
dispatch_queue_t queue = dispatch_queue_create("io.nekohasekai.sing-box.usbhost-controller", DISPATCH_QUEUE_SERIAL);
NSError *error = nil;
IOUSBHostControllerInterface *controller = [[IOUSBHostControllerInterface alloc] initWithCapabilities:capabilities
queue:nil
queue:queue
interruptRateHz:0
error:&error
commandHandler:command_handler
@@ -811,15 +823,27 @@ box_usbhost_controller_t *box_usbhost_controller_create(uintptr_t ref, uint8_t p
box.ref = ref;
box_usbhost_controller_t *handle = calloc(1, sizeof(*handle));
handle->object = (void *)CFBridgingRetain(box);
handle->queue = (void *)CFBridgingRetain(queue);
return handle;
}
}
void box_usbhost_controller_destroy(box_usbhost_controller_t *controller) {
if (controller == NULL) {
return;
}
BoxUSBHostController *box = box_controller(controller);
if (box != nil) {
[box.controller destroy];
}
// Drain the serial command/doorbell queue before releasing the
// BoxUSBHostController wrapper (and, on the Go side, before deleting the
// cgo.Handle paired with the callback ref). This mirrors the watcher's
// teardown.
box_usbhost_drain_and_release_queue(&controller->queue);
if (controller->object != NULL) {
CFBridgingRelease(controller->object);
controller->object = NULL;
}
free(controller);
}