Add a usbdevfs fd with the synchronous ioctls - #366
Conversation
79ff474 to
c22f0d9
Compare
| static void usbdev_retire_unpublished(usbdev_t *u) | ||
| { | ||
| pthread_mutex_lock(&usbdev_table_lock); | ||
| bool mine = !u->dead; |
There was a problem hiding this comment.
mine = !u->dead is the only claim test, so this can retire an entry that is no longer the one this open created. If the guest closes guest_fd right after the publish at 1318, usbdev_fd_cleanup tears this entry down and usbdev_unref frees the slot (used = false); a sibling usbdev_open_path can then take that slot and bind its own live fd before 1334 runs. This call marks that entry dead, frees its blob and closes its pipe_wr, and the new fd answers EBADF on every read and ioctl. Same failure the generation tiebreak in usbdev_fd_cleanup exists to avoid.
Pass guest_fd and gen in, and claim only when u->used && !u->dead && u->guest_fd == guest_fd && u->generation == gen.
There was a problem hiding this comment.
Confirmed and reproduced. With the claim narrowed back to !u->dead and ELFUSE_USBDEV_RETIRE_DELAY_US widening the window, the sibling fd that was never closed answers errno 9 on both pread and GET_SPEED, two assertions failing; with the tuple restored it passes. This lands squarely on our own answer to C10: we added the generation recheck to close the pre-bind window, but the retire helper it calls claimed the slot on "not dead" alone, so in the post-bind window it marked the sibling's live entry dead, freed its blob and closed its pipe. That is the same failure usbdev_fd_cleanup's generation tiebreak already existed to avoid, reintroduced on the other side of the same bind, so the earlier fix was incomplete in one direction and actively harmful in the other. usbdev_retire_unpublished now takes guest_fd and gen and claims u->used && !u->dead && u->guest_fd == guest_fd && u->generation == gen, with all four fields read under usbdev_table_lock so they are read as one value. The window is driven by a new hook in the shape the file already uses, and test-usbdev-faults runs it.
| * a guest racing close() on a guessed fd number lands in a window where | ||
| * usbdev_fd_cleanup cannot find this entry. The publish below closes it. | ||
| */ | ||
| int guest_fd = fd_alloc(FD_USBDEV, pipefd[0], usbdev_fd_cleanup); |
There was a problem hiding this comment.
fd_alloc_from() takes an out_gen captured inside the allocating fd_lock section, which is the proof this path re-derives by hand at 1315. Re-reading after the slot is publishable means that if the guest closes guest_fd and any thread reopens the same number as another FD_USBDEV fd in the window, gen is the other allocation's stamp: the guard at 1334 compares equal, no retire happens, and both entries then match guest_fd == fd && generation == snap.generation in usbdev_acquire, which returns whichever sits lower. The orphan keeps its slot, blob and pipe write end for the life of the process, which is the leak this comment says was closed.
fd_alloc_from(0, FD_USBDEV, pipefd[0], usbdev_fd_cleanup, &gen) removes the window and one fd_lock trip.
There was a problem hiding this comment.
Confirmed and fixed exactly as you describe: fd_alloc_from(0, FD_USBDEV, pipefd[0], usbdev_fd_cleanup, &gen), with the later fd_current_generation read and its fd_lock trip deleted. fd_alloc_from's own comment already documents this contract for dup, so the re-derivation was working against an API that exists to prevent it. This is our own C10 answer again, and the history is the point: before that round the re-read fed only the publish, and the comment above it called the staleness an accepted race. Adding the recheck made it load-bearing without revisiting where the value came from, so in the pre-bind window the generation could be a reopening thread's stamp, both entries held the identical {guest_fd, generation}, the guard compared equal and retired nothing, and usbdev_acquire, which matches on that same pair, answered from whichever sat lower. Reproduced by moving the re-derivation back inside the widened window: the fd read back the idVendor of the other bus and the table went from 32 slots to 31. One correction to a claim I made last round, rather than leave it for you to find: the shipped ELFUSE_USBDEV_PUBLISH_DELAY_US does not expose this by itself, because the pre-fix code took the re-read ahead of the hook, so that lane passes against the old code. The evidence is the mutation, and what closes the window now is that there is no second read left to race.
| * readiness pipe, so nothing below applies. usbdev_ioctl re-snapshots the | ||
| * fd and pins the side-table entry by generation itself. | ||
| */ | ||
| if (fd_get_type(fd) == FD_USBDEV) |
There was a problem hiding this comment.
do_vfs_ioctl handles FIONBIO and FIOASYNC for every file before f_op->unlocked_ioctl runs, so Linux answers them on a usbdevfs fd. This early return sends them into usbdev_ioctl, whose default arm is ENOTTY and whose FMODE_WRITE gate fires first on an O_RDONLY fd: ioctl(fd, FIONBIO, &one) returns 0 and sets O_NONBLOCK on Linux, ENOTTY here (EPERM read-only), while fcntl(fd, F_SETFL, O_NONBLOCK) on the same fd still succeeds. Two entry points onto one flag disagree.
FIOCLEX and FIONCLEX are already tested above this branch; these two belong there.
There was a problem hiding this comment.
Confirmed against a container. On both a char device and a plain file, in all four access modes, Linux answers FIONBIO(1) = 0 and sets O_NONBLOCK, FIOASYNC(1) = ENOTTY, and FIOASYNC(0) = 0. Ours sent both into usbdev_ioctl, so FIONBIO came back EPERM on a read-only fd and ENOTTY on a writable one while fcntl(F_SETFL, O_NONBLOCK) on the same descriptor succeeded and F_GETFL reported the flag, which is the disagreement you name. Both are now answered beside FIOCLEX and FIONCLEX, where do_vfs_ioctl answers them before it calls f_op->unlocked_ioctl (fs/ioctl.c:818-822), so no other fd type's path changes. FIONBIO edits only the guest flag word and always reports 0, as ioctl_fionbio does (fs/ioctl.c:342-363), and the readiness pipe behind the fd stays nonblocking whatever the guest asks. FIOASYNC is not an unconditional 0: ioctl_fioasync consults f_op->fasync only when the request would change the FASYNC state and usbdev_file_operations declares none (devio.c:2846-2856), so asking for the state the fd already has is 0 and asking to change it is ENOTTY. 12 assertions fail with the arm removed.
| */ | ||
| if (bt.len >= (uint32_t) INT32_MAX) | ||
| return -LINUX_EINVAL; | ||
| if (bt.len > USBDEV_BULK_MAX) |
There was a problem hiding this comment.
usbfs_memory_mb is a global in-flight allowance charged by usbfs_increase_memory_usage and refunded on completion, not a per-call size cap. Two consequences: bt.len == USBDEV_BULK_MAX passes here, where Linux charges len + sizeof(struct urb) against the same 16 MB and returns ENOMEM; and nothing accumulates across fds, so a guest holding all 32 side-table slots can keep 32 concurrent 16 MB buffers live in the host.
An atomic byte counter charged here and refunded after the transfer gets both.
There was a problem hiding this comment.
Confirmed. usbfs_memory_mb is one module-global allowance, charged len + sizeof(struct urb) at devio.c:1308 and given back by usbfs_decrease_memory_usage (devio.c:145-178), not a per-call size cap. An atomic counter now carries the total, charged before the buffer is allocated and refunded on every exit from there down, which is why the OUT path's guest_read failure joins the single exit instead of returning from the middle. A transfer of exactly the allowance is now ENOMEM, one just under it goes through twice, and it survives a faulting transfer; each of those three assertions fails under its own mutation, the dropped tail refund and the dropped EFAULT refund included. Two things I did not fix, rather than claim: the per-URB charge is a named constant and not an assertion about sizeof(struct urb), which is kernel-internal and config-dependent and which I cannot measure here, so the INT_MAX - sizeof(struct urb) EINVAL boundary is still spelled INT32_MAX and a narrow band of lengths answers ENOMEM where Linux answers EINVAL, both rejections. And the across-fd accumulation is measured but not asserted: the loopback model retires a transfer before a second can overlap it, and refusing 32 concurrent 16 MB requests would not demonstrate it either, since the per-request boundary already refuses every one of them on its own.
| if (!fd_snapshot(fd, &snap) || snap.type != FD_USBDEV) | ||
| return -LINUX_EBADF; | ||
| /* Every usbdev ioctl needs FMODE_WRITE (devio.c:2607-2608). */ | ||
| if ((snap.linux_flags & LINUX_O_ACCMODE) == LINUX_O_RDONLY) |
There was a problem hiding this comment.
This tests the literal value O_RDONLY, but Linux derives the gate from OPEN_FMODE(flags) = (flags + 1) & O_ACCMODE, which gives access mode 3 neither FMODE_READ nor FMODE_WRITE. open("/dev/bus/usb/001/001", 3) succeeds (0666 node, ACC_MODE(3) is read plus write) and the fd passes this gate, so every ioctl is accepted where Linux answers EPERM. Same shape for the read gates at 1347 and 1385.
Deriving the two capability bits once from (accmode + 1) & 3 covers all four.
There was a problem hiding this comment.
Confirmed, including the reachability. ACC_MODE(3) is "\006", read plus write, so open(node, 3) succeeds on this 0666 node, while OPEN_FMODE(3) is (3 + 1) & O_ACCMODE = 0 and gives neither FMODE_READ nor FMODE_WRITE (fs.h:3630, :3631). Measured in a container on a char device and on a plain file: the open succeeds and read, pread and write are all EBADF. The two capability bits now come from one derivation, and five gates read it rather than four: read, pread, the write refusal, the ioctl gate, and a new read refusal for io.c's empty-vector arm, which carried the same defect and was not in your list. 7 assertions fail with the literal test restored. One more of this shape that the four did not reach, since you asked the general question: the async stage's poll remap decided writability with (flags & O_ACCMODE) != O_RDONLY, where usbdev_poll gates EPOLLOUT|EPOLLWRNORM on FMODE_WRITE (devio.c:2837). It now reads the same derivation. No guest can observe that one today, because reaching POLLOUT needs a reapable completion and SUBMITURB on such an fd is already EPERM, so it is a consistency fix and I have not added an assertion that would have passed with the literal test too.
New src/syscall/usbdev.c/.h: typed FD_USBDEV constructor for
/dev/bus/usb/BBB/DDD (any access mode except O_PATH; the host fd is a
readiness pipe for the async stage's poll), read/pread/readv/lseek
serving the usbfs descriptors blob at a per-open position, fstat as char
189:minor, and the synchronous ioctl set against IOUSBDeviceInterface650
/ IOUSBInterfaceInterface800: CLAIM/RELEASEINTERFACE, SETINTERFACE
(implicit claim + SetAlternateInterface + pipe-map rebuild),
SETCONFIGURATION (-EBUSY while anything on the device is claimed,
device-wide like usb_interface_claimed: this fd's claims, another usbfs
fd's claims via a lock-free per-slot mirror, and bound host drivers via
one registry iterator pass), CLEAR_HALT/RESETEP
(ClearPipeStallBothEnds), GETDRIVER, GET_SPEED (the enum as the return
value), CONNECTINFO, DISCONNECT_CLAIM, USBDEVFS_IOCTL
DISCONNECT/CONNECT, sync CONTROL (DeviceRequestTO) and sync BULK
(Read/WritePipeTO). kIOReturn maps to errno per devio.c: a stall is
-EPIPE, a timeout -ETIMEDOUT, a vanished device -ENODEV, and
kIOReturnAborted is -EINTR with syscall_restart_forbid(), because the
transfer was already on the wire and a dispatcher restart would send it
twice; the eintr contract records ioret_neg_errno as 'forbids'.
The open consults the model, not the hardware. stat, access and an
O_PATH open of the same node are all answered from the model, so
resolving the IOKit service at open time made a plain O_RDONLY the one
entry point that reported ENODEV for a node the other three describe,
and it put the ELFUSE_USB_FIXTURE model, whose devices have no IOKit
service at all, out of reach of every assertion about this fd. The
service is resolved on first use instead, and each operation that needs
the wire reports -ENODEV without one. usb-sysfs models the node 0666 for
the same reason: the owner it reports is the host user, the guest's uid
need not equal it, and 0664 left access(W_OK) refusing what open(O_RDWR)
then served.
Interface arbitration is attempted, not predicted. macOS arbitrates per
IOKit object and dynamically: USBInterfaceOpen answers
kIOReturnExclusiveAccess exactly while a driver holds that interface
open and succeeds while it is idle. So the claim attempts the open and
maps what IOKit answers, rather than refusing on the presence of a
driver child -- which refused work macOS grants. Measured on the
attached ESP32-S3, with the same native IOKit probe run at the same
moment: with nothing holding /dev/cu.usbmodem1101, USBInterfaceOpen on
interface 1 (CDC data, bound to AppleUSBACMData) returns 0x00000000 and
CLAIMINTERFACE 1 now returns 0 where it returned -EBUSY; with the tty
held, both say ExclusiveAccess and -EBUSY. Interface 0 (CDC control) is
ExclusiveAccess and -EBUSY either way. Relatedly, a user client is not a
driver: IOKit publishes an AppleUSBHostInterfaceUserClient child for
every USBInterfaceOpen, so taking the first child of any class reported
a peer usbfs consumer as a bound host driver. GETDRIVER of an interface
another usbfs fd holds now reports usbfs (measured:
driver=AppleUSBHostInterfaceUserClient before, usbfs after).
Kernel-fidelity gates and check order, all of it observable:
- The two capability bits come from OPEN_FMODE, once, and five gates
read them: (flags + 1) & O_ACCMODE, not a comparison against
O_RDONLY (fs.h:3631). Access modes 0, 1 and 2 give FMODE_READ,
FMODE_WRITE and both; access mode 3 gives neither, and it is
reachable -- open(2) takes it and ACC_MODE(3) asks this 0666 node
for read plus write, which it grants. Each gate tested the access
mode itself, so all four agreed on modes 0, 1 and 2 and all four
were wrong on mode 3: read and pread served the descriptors blob and
every ioctl was accepted, where Linux answers -EBADF and -EPERM.
Measured in a container, on a char device and on a plain file: the
open succeeds and read, pread and write are all EBADF.
- Every ioctl needs FMODE_WRITE and is -EPERM without it
(devio.c:2605), ahead of everything else.
- The whole write family is -EBADF without FMODE_WRITE and -EINVAL
otherwise, the order vfs_write checks FMODE_WRITE then
FMODE_CAN_WRITE. write() had this; writev, pwrite and pwritev fell
through to the readiness pipe behind the fd and answered its -EBADF
for a writable fd.
- FIONBIO and FIOASYNC never reach this file's ioctl handler.
do_vfs_ioctl answers both for every file before it calls
f_op->unlocked_ioctl (fs/ioctl.c:818-822), so they also never meet
the FMODE_WRITE gate above. Sent into it they came back -EPERM on a
read-only fd and -ENOTTY on a writable one, while fcntl(F_SETFL,
O_NONBLOCK) on the same descriptor succeeded and F_GETFL reported
the flag: two entry points onto one flag, disagreeing about it. They
are answered where FIOCLEX and FIONCLEX already are, which is where
the kernel puts them relative to the file's own handler, so no other
fd type's path changes. FIONBIO edits only the guest flag word --
the readiness pipe behind the fd stays nonblocking whatever the
guest asks -- and always reports 0, as ioctl_fionbio does
(fs/ioctl.c:342-363). FIOASYNC is not an unconditional 0:
ioctl_fioasync consults f_op->fasync only when the request would
change the FASYNC state and usbdev_file_operations declares none
(devio.c:2846-2856), so asking for the state the fd already has is 0
and asking to change it is -ENOTTY. It must not reach the O_ASYNC
arm in sys_ioctl either, which would arm a SIGIO watcher on a file
the kernel refuses to arm at all. Measured in a container across all
four access modes: FIONBIO(1) is 0 and sets O_NONBLOCK in every one,
FIOASYNC(1) is ENOTTY and FIOASYNC(0) is 0.
- usbfs_memory_mb is one allowance for every transfer in flight, not a
per-call size cap. usbfs_increase_memory_usage charges
len + sizeof(struct urb) against a module-global total and
usbfs_decrease_memory_usage gives it back when the transfer settles
(devio.c:145-178, charged at devio.c:1308). Read as a per-call
ceiling it got both halves wrong: a single request of exactly the
16 MB allowance was accepted, because only the buffer was measured
and the URB was not, and nothing accumulated across calls or across
fds, so a guest holding all 32 side-table slots could keep 32 host
buffers of that size alive at once. An
atomic counter now carries the total, charged where Linux charges it
and refunded on every exit from there down, the EFAULT and IOKit
error arms included, which is why the OUT path's guest_read failure
joins the single exit instead of returning from the middle. The
per-URB overhead is a named constant rather than a claim about
sizeof(struct urb), which is kernel-internal and config-dependent:
what the boundary needs is that the charge strictly exceeds the
length. Measured on the board before and after, interface 1, bulk
OUT ep 0x02: len 16 MB reached the wire and timed out, and is now
-ENOMEM; len 16 MB - 4096 still reaches the wire, twice in a row and
again after a faulting transfer, so both refunds land.
- check_ctrlrecip runs before the wLength cap (devio.c:1177-1183) and
findintfep before the transfer-length checks (devio.c:1289-1298), so
a request naming an endpoint or interface the device does not have
is -ENOENT however long it is.
- The reserved-bit test on an endpoint address (devio.c:860) lives in
the one lookup BULK, CLEAR_HALT, RESETEP and the control endpoint
recipient all reach, so the four cannot disagree about it, and it
tests the caller's whole 32-bit word, as findintfep does. Narrowing
to a byte first made ep 0x183 and 0x01000083 both name endpoint
0x83: measured on the board, all three cleared the stall on 0x83,
where Linux answers -EINVAL for the outer two.
- The interface-number bound is 64: claimintf refuses ifnum >= 8 *
sizeof(ps->ifclaimed) and ifclaimed is an unsigned long (devio.c:75,
:785). Between 64 and 32 an interface is merely absent, a question
about the device, so claimed_mask is a uint64_t. The bound also
applies to the number as it leaves the descriptor: bInterfaceNumber
is a device-supplied byte with the whole 0..255 range behind it, and
the endpoint-owner lookup reports it back as checkintf's -EINVAL
(devio.c:842) rather than handing ifaces[64] an index of 200.
- An altsetting above 255 is -EINVAL, after the implicit claim, the
order proc_setintf decides them in (devio.c:1533-1539):
bAlternateSetting is a byte, so usb_altnum_to_altsetting matches
nothing (usb.c:391) and usb_set_interface refuses (message.c:1548).
Narrowed into SetAlternateInterface's UInt8 instead, altsetting 256
selected setting 0 and took effect -- measured on the board.
- The positional and vector write paths answer in Linux's order too:
ksys_pwrite64 and do_pwritev test pos < 0 before the fd lookup, so a
negative offset is -EINVAL rather than the -EBADF this fd owes for
having no write op; vfs_readv and vfs_writev test the file's mode
before an empty vector returns 0, so readv, preadv and pwritev2 of
nothing still answer by direction; and pwritev2(RWF_APPEND) had no
dispatch of its own and reached the readiness pipe, answering
-ESPIPE.
- Every failure the constructor reports carries the errno of what
failed. ENODEV from the model -- nothing answers to this address --
is the one that becomes the -ENOENT open(2) owes for a name with
nothing behind it; an allocation, a scratch-tree mkdir, or a pipe
the host would not give reports itself, where all three used to be
flattened into -ENOENT or -EMFILE. usbdev_build_pipe_map carries the
IOReturn the same way instead of folding it into -EIO.
- proc_disconnect_claim answers -EINVAL for an interface the device
does not have (devio.c:2467-2469), the opposite of claimintf's
-ENOENT.
- USBDEVFS_IOCTL CONNECT on an interface that already has a driver is
-EBUSY (devio.c:2362-2368); only a free one reaches device_attach.
2357-2361 is the DISCONNECT arm above it.
- SETINTERFACE rewrites -ENOENT and -EPROTO to -EINVAL and passes
every other answer through: the IOReturn SetAlternateInterface
produces for an absent altsetting is not in the errno table and
reached the map's default -EPROTO.
- SEEK_END is -EINVAL, and the new position is range-checked with
__builtin_add_overflow, which lseek(fd, INT64_MAX, SEEK_SET)
followed by lseek(fd, 1, SEEK_CUR) reaches.
- GET_CAPABILITIES reports 0. Every bit in that word names part of the
SUBMITURB/REAPURB machinery, which is -ENOTTY here; the async stage
raises the word as it lands each one.
- A short bulk OUT is -EIO: WritePipeTO reports no length, and
reporting the requested count would spell a partial write as a
complete one.
Locking and lifetime. A lookup pins its entry under usbdev_table_lock
and takes the per-entry lock with the table lock released. Nesting the
two meant a thread blocked on one fd's transfer held the table lock on
every other thread's behalf: measured, a GET_SPEED on an unrelated fd
waited 5517 ms behind a 6 s BULK, and it now returns in 0 ms, as do an
open and a close of the same node. Two ioctls on ONE fd still serialize;
Linux drops the device lock around the URB wait (devio.c:1219/1245,
:1337/1357) and this does not, which is recorded below rather than
claimed as kernel behavior. The teardown hook is handed a bare fd number
after the fd-table slot is already free, so a sibling thread's open can
already hold that number and two entries answer to it; matching on the
number alone tore down whichever sat lower in the table, about half the
time the live one. fd_alloc stamps a globally monotonic generation, so
the closing entry is the one with the smaller generation. Measured with
the mutation restored, three runs of 8000: 281, 313 and 371 freshly
opened fds lost, and 0 with it on every run. The count is a race and
varies; that it is never 0 without the tiebreak is the point.
fd_alloc also publishes the fd number before the side table can bind it,
and the same hook matches on that number, so a close landing in that
window found nothing to tear down and left the entry, its blob and its
pipe held for the life of the process. The open therefore rereads the
fd's generation once its entry is findable and retires the entry itself
if the close has been and gone.
That leaves two windows around the bind rather than one, and the entry
has to keep one identity across both. Before the bind its guest_fd is
still -1, so a close finds nothing; after the bind it is findable and
therefore also freeable, so a close can reap it, usbdev_unref can free
the slot, and a sibling open can take that slot and bind its own live fd
before the recheck runs. Two things make the identity unambiguous. The
generation is fd_alloc_from's out parameter, captured inside the fd_lock
section that stamped it, rather than a later read of the slot:
re-derived after the slot was publishable it was whatever the number
carried by
then, which in the first window is a reopening thread's stamp, and two
entries then held the identical pair -- the guard compared equal and
retired nothing, and usbdev_acquire, which matches on that same pair,
answered from whichever sat at the lower index. And the retire path
claims the whole tuple the caller allocated -- used and alive, this fd
number, this generation -- rather than testing that the slot is not
dead: in the second window that test claimed the sibling's entry, marked
it dead, freed its blob and closed its pipe, and the sibling's
still-open fd answered EBADF on every read and ioctl, which is the
failure the
teardown hook's generation tiebreak exists to avoid, reintroduced on the
other side of the same bind. What makes the tuple sufficient is that
slot storage is static, so the read is always defined; that all four
fields are written and read under usbdev_table_lock, so they are read as
one value; and that fd_next_generation is globally monotonic, so each
entry carries its own allocation's stamp and no two live entries can
present the same pair.
Both windows are driven by a widening hook, and what each one can show
differs, so this says which is an assertion and which is a mutation.
ELFUSE_USBDEV_PUBLISH_DELAY_US covers the first. A close inside it leaks
a slot with the recheck deleted: 32 simultaneous opens before the race,
31 after, over 4 rounds. The generation half of that window cannot be
shown the same way, because taking the stamp from the allocator removes
the re-derivation rather than guarding it -- there is no longer a read
to race. Moving the re-derivation back inside the widened window does
show it: with the reopen naming the other bus, the fd read back the
idVendor of the device it was not opened on and a slot went missing, 32
before and 31 after. Left where the pre-fix code had it, ahead of the
hook, the same lane passes, so this window is guarded by the shape of
the code and by that mutation, not by a lane that fails without the fix.
ELFUSE_USBDEV_RETIRE_DELAY_US covers the second, and that one is a lane:
with the claim narrowed back to 'not dead', the reopened fd, which was
never closed, answered EBADF (errno 9) on pread and on GET_SPEED.
Plumbing: FD_USBDEV in linux-wire.h (+LINUX_EPIPE/LINUX_ETIME), dispatch
hooks in io.c (ioctl/read/write/writev/pwrite/pwritev/pwritev2 and the
empty-vector arm), fs.c (openat constructor, lseek arm, dup refusal),
fs-stat.c (fstat, ahead of the /dev/bus path stamp, which otherwise
claimed every one of these fds and left the typed answer unreachable),
usbdev_init in syscall_init, usb-sysfs exports
usb_sysfs_device_info/node_stat.
usbdev_table_lock is registered in internal.h's lock order, whose entry
now describes what the code does -- table lock dropped before the entry
lock, refs and dead pinning the slot across the gap -- rather than a
nesting no path takes. The constructor takes its generation from the
allocator rather than reading it back, which removes an fd_lock trip as
well as the window, and the tuple it later tests is read under
usbdev_table_lock alone, so fd_lock never nests under it. The
completion-pipe fds are initialized to -1 so the merged error arm cannot
close two indeterminate descriptors when pipe() itself fails.
Tests. tests/test-usbdev-ioctl.c is a new hardware-free lane: the
fixture's devices are modeled with no IOKit service behind them, which
is exactly the shape of a device the machine cannot reach, so the file
contract, the ioctl gates and the whole argument-validation surface get
92 assertions that answer the same on any host. It carries the four-
thread open/read/close churn that the teardown identity needs, and
prints each known gap as an XFAIL with both values. The
ELFUSE_USB_FIXTURE model grew the endpoint descriptor its interface
descriptor already claimed, so the endpoint-addressed paths have
something to resolve against; tests.mk keeps the fixture on the test-
usb-sysfs lane, which the earlier draft had removed after the
constructor stopped serving modeled devices.
test-usbdev-faults runs the same binary six more times, forcing one
condition each, because none of the six can be provoked from a guest on
a healthy host. ELFUSE_USB_FIXTURE=badifnum stands up a device whose one
interface declares bInterfaceNumber 200 and carries endpoint 0x81: every
byte is well formed and the range is the field's own, so nothing anyone
can plug in emits it. The answer is -EINVAL on both sides of the bound,
which is what checkintf answers, and what changes is that nothing reads
out of bounds first -- with the bound removed and the fixture in place,
UBSAN reports "index 200 out of bounds for type usbdev_iface_t[64]" at
the array read and aborts the run. ELFUSE_USBDEV_OPEN_FAULT fails the
model lookup and the descriptor copy with ENOMEM and the readiness pipe
with ENFILE, which the guest saw as ENOENT, ENOENT and EMFILE before.
ELFUSE_USBDEV_PUBLISH_DELAY_US and ELFUSE_USBDEV_RETIRE_DELAY_US widen
the two windows around the side-table bind for the races above; each run
asserts only its own window. All three hooks are read once and do
nothing when unset, the same shape as the directory and identity lanes'
hooks,
and all three are recorded in docs/testing.md.
The allowance needs a device whose interface can actually be claimed,
which the service-less fixture has none of: the claim answers -ENODEV
before any length is looked at. So that check says so and stands down
here, and is covered where there is one -- on the board, and on the
loopback model the async stage adds, which runs this same binary.
Known gaps, each printed by that lane as an XFAIL and listed in
internals.md: no disconnect gate (Linux answers -ENODEV for every ioctl
once the device is gone; the answers served from the open-time model
still report it, and noticing the disconnect needs an IOKit termination
notification on a run loop, which is the async stage's machinery); two
ioctls on one fd serialize; GETDRIVER reports the IOKit class name where
Linux reports the driver name, and DISCONNECT_CLAIM's name filters
compare against it; RESET clears the claimed pipes' stalls instead of
re-enumerating (ReEnumerate would destroy the handles); a short bulk OUT
is -EIO; the side table holds 32 open fds per process and reports
-ENOMEM past that, where Linux allocates a usb_dev_state per open;
USBDEVFS_IOCTL DISCONNECT of an interface another usbfs fd holds is
-EBUSY where Linux releases that claim; dup() is -EBADF and fork drops
the fd (fd_type_is_synthetic).
Document the layer as internals.md USB Device Passthrough.
Verified on the attached ESP32-S3 (303a:1001, modeled as
/dev/bus/usb/002/001), every line re-run against this build: CLAIM
iface2 = 0, CLAIM iface1 = 0 with the tty free and -EBUSY with it held,
CLAIM iface0 = -EBUSY; GETDRIVER(0) = AppleUSBACMControl, GETDRIVER(5) =
-ENODATA, GETDRIVER(2) from a second fd = usbfs; SETINTERFACE 2/9 =
-EINVAL, 2/0 = 0; DISCONNECT_CLAIM iface5 = -EINVAL; USBDEVFS_IOCTL
CONNECT iface1 = -EBUSY, iface5 = -EINVAL; CONTROL GET_DESCRIPTOR = 18
bytes matching the first 18 of the 116-byte read(); CLEAR_HALT 0x83 = 0,
CLEAR_HALT 0x30 = -EINVAL; BULK OUT 1 byte = 1, BULK on absent ep 0x09
with len 32 MB = -ENOENT, BULK IN on the idle ep 0x83 with a 5000 ms
timeout = -ETIMEDOUT after 5038 ms; GET_CAPABILITIES = 0, GET_SPEED = 2
(FULL), CONNECTINFO devnum 1. The arguments that used to be narrowed,
same board, iface2 claimed: CLEAR_HALT 0x183 = -EINVAL and 0x01000083 =
-EINVAL where all three used to clear the stall on 0x83 and return 0,
RESETEP 0x183 = -EINVAL, CLEAR_HALT 0x83 = 0 still, BULK ep 0x102 =
-EINVAL, SETINTERFACE 2 alt 256 = -EINVAL where it used to return 0
having selected setting 0, alt 7 = -EINVAL, alt 0 = 0, and CONTROL
GET_STATUS with wIndex 0x0183 = 2 bytes, since check_ctrlrecip masks
wIndex to its low byte before the lookup (devio.c:904) and that path
must not gain the wider test.
c22f0d9 to
dfdd55c
Compare
|
Thank @jotpalch for contributing! |
NOTE: piece C of the USB layer described in #319, on top of #334 and #351. It adds the usbdevfs descriptor and the synchronous half of its ioctl surface. Transfers that need the asynchronous engine (SUBMITURB, REAPURB, DISCARDURB) are the next piece and are not here; this one answers ENOTTY for them and reports no URB capability bits, which is what makes that honest.
Summary
A guest that opens
/dev/bus/usb/BBB/DDDgets a descriptor whose file contract and ioctl surface followdrivers/usb/core/devio.c: the permission answers, the write family, the file position, the argument validation the kernel does before it touches the wire, and the synchronous operations mapped ontoIOUSBDeviceInterface650andIOUSBInterfaceInterface800.CLAIMINTERFACE,RELEASEINTERFACE,SETINTERFACE,SETCONFIGURATION,CONTROL,BULK,CLEAR_HALT,RESETEP,RESET,GETDRIVER,DISCONNECT_CLAIM,IOCTL,CONNECTINFO,GET_SPEEDandGET_CAPABILITIESare implemented.Driver arbitration is per object and dynamic, not per device
The one design point worth stating up front, because it changed after measurement. macOS arbitration is not a property of a device class that can be decided in advance. A device-level open succeeds whenever the bound driver holds no exclusive device open, and an interface-level open succeeds unless that specific driver currently holds that specific interface. So the layer attempts the open and maps the IOKit result rather than refusing by class.
Measured on an attached ESP32-S3 (
303a:1001), with a native IOKit probe run at the same moment as the guest call:An earlier revision refused interface 1 outright because a driver was bound to it, which refused work macOS allows. A tool that talks to a board over the vendor interface while the CDC tty stays available to the rest of the system is the case this serves.
What the argument validation follows
Each of these is a devio.c line rather than a guess, and each has an assertion in the new lane.
O_RDONLYdescriptor isEPERM(devio.c:2605), which isFMODE_WRITEand not a permission check on the node.check_ctrlrecipruns before thewLengthcap (devio.c:1176-1181), andfindintfepbefore the transfer-length checks (devio.c:1289-1298), so an absent endpoint outranks a malformed length rather than the other way round.devio.c:860) lives in the one lookup all four endpoint ioctls share.claimintfrefusesifnum >= 8 * sizeof(ps->ifclaimed)andifclaimedis anunsigned long(devio.c:75, :785). Between 32 and 64 an interface is merely absent, which is a question about the device, so the claimed mask is auint64_t.DISCONNECT_CLAIManswersEINVALfor an interface the device does not have (devio.c:2467-2469), the opposite ofclaimintf'sENOENT.USBDEVFS_IOCTLwithCONNECTon an interface that already has a driver isEBUSY(devio.c:2357-2361); only a free one reachesdevice_attach.Deviations, recorded rather than implied
These are printed by the lane as XFAIL rows carrying both values, so they stay visible in its own output rather than living only in a commit message.
RESET: Linux re-enumerates the port. IOKit's equivalent isUSBDeviceReEnumerate, which is an unplug that tears down the user client, so this clears the claimed pipes' stalls and returns 0 instead.ENODEVfor every ioctl. This still servesGET_SPEED,CONNECTINFO,GET_CAPABILITIESandread()from the model captured at open, because noticing the departure needs an IOKit termination notification on a run loop, which is the next piece's machinery.GETDRIVERreports the IOKit class (AppleUSBACMControl) where Linux reports the driver name (cdc_acm), andDISCONNECT_CLAIM's name filters compare against it. A guest asking to detachcdc_acmby name will not match.EIO:WritePipeTOreturns no length, so the byte count Linux would report is not available.ENOMEMpast that, where Linux has no such limit.Tests
tests/test-usbdev-ioctl.cis a new lane, 54 assertions, that needs no hardware: it runs againstELFUSE_USB_FIXTURE, whose devices are modeled but have no IOKit service behind them, which is exactly the shape of a device the model knows and the machine cannot reach. It covers the half of usbdevfs decided before the wire. Run without the fixture it prints its unmet precondition and exits rather than reporting assertions that failed for the wrong reason.The half that needs a device, a real claim and a real transfer and arbitration against a bound host driver, is verified on the attached board and is not pretended to be covered by the lane.
Evidence
Summary by cubic
Implements the synchronous usbdevfs portion described in #319. Writable opens of
/dev/bus/usb/BBB/DDDpreviously returnedEACCES; they now create a typed fd that serves the descriptor blob and maps synchronous ioctls to IOKit, while async URBs remainENOTTYwith no capability bits. Wire access resolves lazily, so modeled devices can open without a matching host service and reportENODEVonly when an operation needs hardware.Behavior
EBUSYonly while the interface is held.usbfs_memory_mballowance across in-flight transfers, refunding on every exit path.read,pread,readv, andlseekfrom a per-open descriptor position and reportsfstatas USB char device189:minor.dup()and fork inheritance;O_PATHcontinues using the existing path-only fd.Tests and known gaps
GETDRIVER, non-reenumeratingRESET, short bulk OUT asEIO, the 32-fd side-table limit, and cross-fd disconnect behavior.Written for commit dfdd55c. Summary will update on new commits.