Skip to content

Add USB hot-plug for sticks, hubs and keyboards - #3256

Merged
valentinbreiz merged 12 commits into
feature/usb-mass-storagefrom
feature/usb-hotplug
Sep 24, 2026
Merged

valentinbreiz merged 12 commits into
feature/usb-mass-storagefrom
feature/usb-hotplug

Conversation

@valentinbreiz

Copy link
Copy Markdown
Member

Stacked on #3255 (USB mass storage): the base is feature/usb-mass-storage, to be retargeted to gen3 once #3254 and #3255 merge.

Problem

USB devices were enumerated once, at boot, and nothing listened for port changes after that. A stick plugged in later never showed up, and one pulled out stayed registered. StorageManager, its partitions and any filesystem mounted from them kept pointing at a device whose xHCI slot no longer answered. The same held for keyboards and hubs. Switching sticks meant rebooting.

Fix

Hot-plug thread. UsbManager.StartHotPlug, called from Kernel.Start once interrupts are on, starts a thread that waits for port changes.

  • Interrupt handlers signal it:

    • the xHCI Port Status Change Event for root ports;
    • a hub's status change endpoint (interrupt IN, one bit per port) for hub ports.

    A controller without MSI-X (arm64 with GICv2) is polled every 250 ms instead.

  • Root ports (XhciController.HandlePortChanges): the change bits are cleared first (RW1C). On a connect change, or an enable change that left the port disabled, the thread:

    • drops whatever was on the port;
    • if something is connected, waits out a 100 ms debounce and enumerates it.
  • Hub ports (the new UsbHub, split out of UsbHubDriver): GET_STATUS, then every change feature is cleared (the USB 2.0 set or the SuperSpeed set), including the hub-level local power and over-current changes. The port is handled the same way as a root port.

  • Thread.Start returns only once the new thread has run, and only a scheduler tick can run it. On x64 with ACPI off the LAPIC timer is never calibrated, so the Storage suite's usb+acpi-off cell hung there. StartHotPlug now waits up to 50 ms for a tick first. Without one, it logs Scheduler timer not ticking, hot-plug disabled and the devices stay the ones found at boot.

Disconnect.

  • The interrupt handler already marks every device on a root port that lost its connection (UsbDevice.IsDisconnected). Control and bulk transfers waiting on such a device return the new UsbTransferStatus.Disconnected instead of running into their timeout, and endpoint recovery is skipped.
  • The thread then releases the subtree, children before parents. For each device:
    1. every class driver bound to it gets UsbDriver.Disconnect(device, interface), which is new and abstract;
    2. the device leaves UsbManager.Devices;
    3. ReleaseDevice runs.
  • ReleaseDevice takes the control mutex and every bulk pipe's mutex, so no transfer still uses the device's memory. It then runs Disable Slot, clears the slot under the event and ring locks, and frees the memory.

Class drivers.

  • The mass storage and keyboard drivers keep copy-on-write lists. They report units that come and go through DiskAttached/DiskDetached and KeyboardAttached/KeyboardDetached.
  • A removed UsbMassStorage throws IOException ("USB mass storage device usbN was removed.") from ReadBlock, WriteBlock and Flush, and no longer retries a transport error.
  • A stick takes the lowest free usbN, so one plugged back in gets its name back.

System.

  • StorageManager registers a stick that arrives like one found at boot, partition scan included.
  • StorageManager.UnregisterDevice drops a stick that leaves together with its partitions and moves the primary device on. It then calls VfsManager.DetachMounts, which drops every mount made from one of those partitions without flushing, since the disk is gone.
  • The device, partition, mount and keyboard tables are replaced whole on each change, under a lock. A reader on another thread never sees a half-updated list.
  • Partition scans run outside the lock and are published only if their device is still registered.
  • TryUnmount drops the mount outside the lock. TryFormat refuses a mounted partition.

DevKernel.

  • New umount <mountpoint> command, which also moves the shell out of the mount.
  • An IOException from a command, such as a stick pulled out under cat, is reported and the shell keeps running.
  • The boot stick is auto-mounted through its Partition, so it is detached if pulled out.

Tests. A test can now ask the engine to change the machine under the running guest.

  • TR.RequestHost("usb-unplug") sends a new HostRequest frame (109). The engine's UART monitor picks it up as it arrives and carries it out over QMP:
    • unplug: device_del;
    • plug: drive_add plus device_add on the same image.
  • QEMU connects its monitor out to a port the engine already listens on (-chardev socket,host=127.0.0.1,port=N -mon chardev=qmp0,mode=control), so there is no port race. Only profiles with a USB disk get a monitor.
  • Nothing replies: the test waits for the change itself, within the 10 s the engine allows without a protocol message.
    • Rejected: QMP's blockdev-add for the replug. A node added that way outlives its device and keeps the image open and locked, so plugging the same image in again fails. A drive_add drive is deleted along with its device, like the command-line -drive.
  • The Storage suite gains 5 tests in the usb cells (68 → 73 per cell):
Test Checks
UsbHotPlug_UnplugUnregistersDisk the stick leaves StorageManager and the driver, and is marked removed
UsbHotPlug_RemovedDiskFailsIo a read on the removed stick throws IOException
UsbHotPlug_ReplugRegistersDisk it comes back as a new device, with the same name and size
UsbHotPlug_ReplugKeepsData a sector written before the unplug reads back
UsbHotPlug_UnplugDetachesMount a mount from its partition is detached and the partition dropped; after replugging, it mounts again and the file written before is intact

They skip on the other cells, and where the hot-plug thread cannot run (x64 with ACPI off).

Verification

Real hardware: not tested yet.

DevKernel, QEMU x64 (KVM). Sticks and hubs were added and removed from the QEMU monitor with drive_add/device_add/device_del:

Case Result
Stick plugged in after boot usb0 registered; diskinfo, mount, cat, write work
Pulled out while mounted, shell inside the mount the mount is detached, the shell survives
Two sticks, one pulled out the other stick's mount keeps working and the primary device moves; a replugged stick gets usb0 back with its data
Pulled out during format the shell reports the I/O error and keeps running
Pulled out during a throttled cat (256 KiB/s) aborts at once: I/O error: USB mass storage device usb1 was removed.
Stick on a hub port, in and out followed through the hub's status change endpoint
Hub with a stick behind it removed stick released before the hub
Hub with a stick behind it hot-plugged both enumerated
usb-kbd in and out registered with and dropped from KeyboardManager
umount unmounts, and moves the shell to / if it was inside

DevKernel, QEMU arm64:

  • GICv3 (MSI-X): plug, write, cat, unplug. The host reads the written file back from the image.
  • GICv2 (polled): plug, cat, unplug.

Storage suite, x64 (make test KERNEL=Storage): 6 cells, 0 failed.

  • The usb cell passes all 5 hot-plug tests. Its first boot logs:
[USB] xHCI root port 1: disconnected
[USB storage] usb0 removed
[StorageManager] usb0 unregistered
[USB] xHCI root port 2: 46F4:1 SuperSpeed, 1 interface(s)
[USB storage] usb0 (LUN 0): QEMU QEMU HARDDISK, 524288 blocks of 512 bytes
[StorageManager] Unpartitioned filesystem volume detected on usb0
...
[USB] xHCI root port 2: disconnected
[USB storage] usb0 removed
[StorageManager] usb0 unregistered
[VFS] /usbstick detached: its disk is gone
  • The engine reports [HotPlug] usb-unplug: done and [HotPlug] usb-plug: done twice per boot.
  • usb+acpi-off now runs to the end instead of timing out. It logs [USB] Scheduler timer not ticking, hot-plug disabled and skips the hot-plug tests.

Storage suite, arm64 (TIMEOUT=90): 18 cells, 0 failed, 0 timed out.

Cells Hot-plug tests
usb, usb+gicv2 (polled, no ITS), usb+gicv3 (MSI-X) 5/5 passed each, 74 passed per cell in all
usb+acpi-off, usb+gicv2+acpi-off, usb+gicv3+acpi-off skipped: no stick found (no PCIe discovery without ACPI, as for ahci and nvme)
ahci and nvme cells skipped: not a USB profile

Other checks:

  • Fat 42/42 and File 37/37 pass (x64).
  • dotnet test tests/Cosmos.Tests.Patcher --filter QemuLauncher: 44 passed.
  • dotnet format whitespace --verify-no-changes is clean on the changed files.

Known gaps:

  • A filesystem mounted from a source string (TryMount("fat", "3", ...)) is not tied to a Partition, so it stays mounted after its disk leaves. Its I/O then throws IOException. Only mounts made through the Partition overload are detached.
  • TryMount(Partition) does not hold the storage lock while it mounts. A stick pulled out at that exact moment can leave that one mount behind.
  • A key held down while its keyboard is pulled out stays down in the modifier state.
  • Without a ticking scheduler timer (x64 with ACPI off), hot-plug stays off.
  • xHCI only.

A hot-plug thread, started once the scheduler's timer ticks, waits for
port changes: the xHCI Port Status Change Event on a root port, the
status change endpoint on a hub port, or a 250 ms poll when the
controller has no MSI-X. It clears the change bits, drops whatever sat
on a port that disconnected, and after a 100 ms debounce enumerates what
was plugged in.

A device that leaves is marked disconnected from the interrupt handler
already, so transfers waiting on it return the new Disconnected status
instead of timing out and skip recovery. Its subtree is then released
children first: each class driver's Disconnect, the device leaving the
manager's list, and the controller disabling its slot once no control or
bulk transfer still holds its memory.

Hub port handling moves into UsbHub, which also follows the hub's status
change endpoint. The mass storage and keyboard drivers keep copy-on-write
lists and report units that come and go through attach/detach callbacks.
I/O on a removed stick throws IOException at once, and names are reused
from the lowest free usbN. Without the scheduler's timer (x64 with ACPI
off) hot-plug stays off and the devices are the ones found at boot.
Kernel.Start starts the USB hot-plug thread once interrupts are on. A
stick that shows up is registered and its partitions scanned like one
found at boot; one that goes away is unregistered with its partitions,
the primary device moves on, and every filesystem mounted from one of
its partitions is detached without a flush, since the disk is gone. USB
keyboards join and leave KeyboardManager the same way.

The device, partition, mount and keyboard tables are replaced whole on
every change instead of changed in place, so a reader on another thread
always sees a consistent list. Partition scans run outside the lock and
are published only if their device is still registered. TryUnmount
drops the mount outside the lock, and TryFormat refuses a partition that
is mounted.
`umount <mountpoint>` unmounts and moves the shell out of it. An I/O
error, such as a stick pulled out under a running command, is reported
and the shell keeps going. The boot stick is auto-mounted through its
partition, so it is detached if pulled out, and an empty disk list now
says a USB disk can be plugged in.
USB sticks and keyboards can be plugged in and pulled out while the
kernel runs. Explain what happens to mounts (partition mounts are
detached, source-string mounts stay), to unmount before pulling a
stick, how to try it from the QEMU monitor, and where Kernel.Start
starts the hot-plug thread.
TR.RequestHost sends a new HostRequest frame (109), and the engine acts
on it as soon as it shows up on the UART: `usb-unplug [n]` removes stick
n from the xHCI controller with device_del, and `usb-plug [n]` puts it
back on the same image with drive_add and device_add. It gets there
through QMP: a profile with a USB disk launches QEMU with its monitor
connected to a port the engine listens on (QemuLaunchOptions.MonitorPort),
and USB sticks get stable QEMU ids. Nothing replies; the test waits for
the change, within the 10 s the engine gives it before taking the guest
for hung.
Five tests in the usb cells pull the stick out and plug it back in:
the stick leaves StorageManager and the driver, I/O on it throws
IOException, it comes back under its old name with the sector written
before the unplug, and a filesystem mounted from its partition is
detached with it, then mounts again with its file intact. They skip on
other cells and where the hot-plug thread cannot run (x64 with ACPI
off).
@github-actions

github-actions Bot commented Sep 23, 2026 •

Copy link
Copy Markdown

🧪 Fat Tests

Cell x64 arm64
all ✅ 42/42 ✅ 42/42

📎 Artifacts

Architecture Test Results UART Log Kernel ISO
x64 XML Log ISO
arm64 XML Log ISO

📋 View full test summary | 📄 Kernel.cs

@github-actions

github-actions Bot commented Sep 23, 2026 •

Copy link
Copy Markdown

🧪 File Tests

Cell x64 arm64
all ✅ 37/37 ✅ 37/37

📎 Artifacts

Architecture Test Results UART Log Kernel ISO
x64 XML Log ISO
arm64 XML Log ISO

📋 View full test summary | 📄 Kernel.cs

@github-actions

github-actions Bot commented Sep 23, 2026 •

Copy link
Copy Markdown

🧪 Graphic Tests

Cell x64 arm64
all ✅ 49/75, 26 skip ⚠️ 24/50, 26 skip
bare ⚠️ 12/25, 13 skip ⚠️ 12/25, 13 skip
virtio-gpu ⚠️ 12/25, 13 skip ⚠️ 12/25, 13 skip
vmware-svga ✅ 25/25 n/a

Skipped

  • x64: 26 skipped: VMware SVGA II adapter not present, needs the vmware-svga profile (26)
  • arm64: 26 skipped: VMware SVGA II adapter not present, needs the vmware-svga profile (26)

📎 Artifacts

Architecture Test Results UART Log Kernel ISO
x64 XML Log ISO
arm64 XML Log ISO

📋 View full test summary | 📄 Kernel.cs

@github-actions

github-actions Bot commented Sep 23, 2026 •

Copy link
Copy Markdown

🧪 HelloWorld Tests

Cell x64 arm64
all ✅ 3/3 ✅ 3/3

📎 Artifacts

Architecture Test Results UART Log Kernel ISO
x64 XML Log ISO
arm64 XML Log ISO

📋 View full test summary | 📄 Kernel.cs

@github-actions

github-actions Bot commented Sep 23, 2026 •

Copy link
Copy Markdown

🧪 Interrupts Tests

Cell x64 arm64
all ✅ 12/12 ✅ 25/36, 11 skip
bare ✅ 12/12 ✅ 7/12, 5 skip
bare+gicv2 n/a ✅ 9/12, 3 skip
bare+gicv3 n/a ✅ 9/12, 3 skip

Skipped

  • arm64: 11 skipped: self-IPI harness is x64-only (3), LAPIC MSI address contract is x64-only (3), LAPIC timer vector is x64-only (3), gic-version not pinned by this cell (2)

📎 Artifacts

Architecture Test Results UART Log Kernel ISO
x64 XML Log ISO
arm64 XML Log ISO

📋 View full test summary | 📄 Kernel.cs

@github-actions

github-actions Bot commented Sep 23, 2026 •

Copy link
Copy Markdown

🧪 Math Tests

Cell x64 arm64
all ✅ 30/30 ✅ 30/30

📎 Artifacts

Architecture Test Results UART Log Kernel ISO
x64 XML Log ISO
arm64 XML Log ISO

📋 View full test summary | 📄 Kernel.cs

@github-actions

github-actions Bot commented Sep 23, 2026 •

Copy link
Copy Markdown

🧪 Memory Tests

Cell x64 arm64
all ✅ 71/71 ✅ 71/71

📎 Artifacts

Architecture Test Results UART Log Kernel ISO
x64 XML Log ISO
arm64 XML Log ISO

📋 View full test summary | 📄 Kernel.cs

@github-actions

github-actions Bot commented Sep 23, 2026 •

Copy link
Copy Markdown

🧪 Network Tests

Cell x64 arm64
all ✅ 48/48 ✅ 48/48
e1000e ✅ 24/24 n/a
virtio-net-mmio n/a ✅ 24/24
virtio-net-pci ✅ 24/24 ✅ 24/24

📎 Artifacts

Architecture Test Results UART Log Kernel ISO
x64 XML Log ISO
arm64 XML Log ISO

📋 View full test summary | 📄 Kernel.cs

@github-actions

github-actions Bot commented Sep 23, 2026 •

Copy link
Copy Markdown

🧪 Pci Tests

Cell x64 arm64
all ✅ 6/6 ✅ 6/6

📎 Artifacts

Architecture Test Results UART Log Kernel ISO
x64 XML Log ISO
arm64 XML Log ISO

📋 View full test summary | 📄 Kernel.cs

@github-actions

github-actions Bot commented Sep 23, 2026 •

Copy link
Copy Markdown

🧪 Power Tests

Cell x64 arm64
all ✅ 4/4 ✅ 4/4

📎 Artifacts

Architecture Test Results UART Log Kernel ISO
x64 XML Log ISO
arm64 XML Log ISO

📋 View full test summary | 📄 Kernel.cs

@github-actions

github-actions Bot commented Sep 23, 2026 •

Copy link
Copy Markdown

🧪 Runtime Tests

Cell x64 arm64
all ✅ 103/103 ✅ 103/103

📎 Artifacts

Architecture Test Results UART Log Kernel ISO
x64 XML Log ISO
arm64 XML Log ISO

📋 View full test summary | 📄 Kernel.cs

@github-actions

github-actions Bot commented Sep 23, 2026 •

Copy link
Copy Markdown

🧪 Storage Tests

Cell x64 arm64
all ✅ 426/468, 42 skip ⚠️ 668/1404, 736 skip
ahci ✅ 69/78, 9 skip ✅ 69/78, 9 skip
ahci+acpi-off ✅ 69/78, 9 skip ⚠️ 3/78, 75 skip
ahci+gicv2 n/a ✅ 69/78, 9 skip
ahci+gicv2+acpi-off n/a ⚠️ 3/78, 75 skip
ahci+gicv3 n/a ✅ 69/78, 9 skip
ahci+gicv3+acpi-off n/a ⚠️ 3/78, 75 skip
nvme ✅ 73/78, 5 skip ✅ 70/78, 8 skip
nvme+acpi-off ✅ 72/78, 6 skip ⚠️ 3/78, 75 skip
nvme+gicv2 n/a ✅ 71/78, 7 skip
nvme+gicv2+acpi-off n/a ⚠️ 3/78, 75 skip
nvme+gicv3 n/a ✅ 71/78, 7 skip
nvme+gicv3+acpi-off n/a ⚠️ 3/78, 75 skip
usb ✅ 74/78, 4 skip ✅ 74/78, 4 skip
usb+acpi-off ✅ 69/78, 9 skip ⚠️ 3/78, 75 skip
usb+gicv2 n/a ✅ 74/78, 4 skip
usb+gicv2+acpi-off n/a ⚠️ 3/78, 75 skip
usb+gicv3 n/a ✅ 74/78, 4 skip
usb+gicv3+acpi-off n/a ⚠️ 3/78, 75 skip

Skipped

  • x64: 42 skipped: not a USB profile (20), 64-bit BAR relocation probe is nvme-profile only (8), USB hot-plug thread not running (scheduler timer not ticking) (5), not an NVMe profile (4), NVMe controller API is nvme-profile only (4), acpi-off has no MSI routing to pin (1)
  • arm64: 736 skipped: no block device bound for partition-table tests (441), no block device bound for this profile (171), not a USB profile (60), x64 mapper cell; arm64 installs Device mappings via DeviceMapper (18), BAR relocation probe is x64-only (same harness as the mapper cell) (18), NVMe controller API is nvme-profile only (15), not an NVMe profile (12), interrupt mode not pinned by this cell (1)

📎 Artifacts

Architecture Test Results UART Log Kernel ISO
x64 XML Log ISO
arm64 XML Log ISO

📋 View full test summary | 📄 Kernel.cs

@github-actions

github-actions Bot commented Sep 23, 2026 •

Copy link
Copy Markdown

🧪 Threading Tests

Cell x64 arm64
all ✅ 76/76 ✅ 76/76

📎 Artifacts

Architecture Test Results UART Log Kernel ISO
x64 XML Log ISO
arm64 XML Log ISO

📋 View full test summary | 📄 Kernel.cs

@github-actions

github-actions Bot commented Sep 23, 2026 •

Copy link
Copy Markdown

🧪 Timer Tests

Cell x64 arm64
all ✅ 26/26 ✅ 20/20

📎 Artifacts

Architecture Test Results UART Log Kernel ISO
x64 XML Log ISO
arm64 XML Log ISO

📋 View full test summary | 📄 Kernel.cs

@github-actions

github-actions Bot commented Sep 23, 2026 •

Copy link
Copy Markdown

🧪 Virtio Tests

Cell x64 arm64
all ✅ 10/10 ✅ 17/20, 3 skip
virtio-mmio n/a ✅ 7/10, 3 skip
virtio-pci ✅ 10/10 ✅ 10/10

Skipped

  • arm64: 3 skipped: this cell presents virtio over MMIO (3)

📎 Artifacts

Architecture Test Results UART Log Kernel ISO
x64 XML Log ISO
arm64 XML Log ISO

📋 View full test summary | 📄 Kernel.cs

Bring the files this branch adds or changes in line with
docs/articles/dev/coding-guidelines.md. No behavior changes.

- Move constructors after fields and properties.
- Split UsbEndpointType into its own file.
- Replace pass-through fields with get-only auto-properties.
- Use explicit types instead of var, is null / is not null instead of
  == null / != null, interpolation instead of concatenation, and
  collection expressions.
- Add license headers and a <summary> to QemuHotPlug.DisposeAsync.
- Rename HostRequestScanner.Magic to s_magic.
- Remove comments that only restate the code.
- Explain why the USB drivers' lazily filled statics stay null.
Track .claude/agents/ so every contributor gets the code-cleaner agent,
which reads docs/articles/dev/coding-guidelines.md and cleans source
files to it without changing behavior. The rest of .claude, such as
settings.local.json, stays ignored.
The parser's range check stopped at TestDestructiveReached (108), so
HostRequest (109) frames were skipped as noise and their case never ran.
DisposeAsync waited for the monitor connection before it stopped the
listener. If QEMU exited before connecting, for example because it
rejected an argument, the accept never completed: its token belongs to a
source the host disposes without cancelling. The engine then hung at
await using. Stop the listener first, which ends the pending accept.
IndexOf and LastIndexOf both return -1 when qemu-xhci is missing, so the
one-controller check passed with no controller at all. Assert that the
controller is there too.
Move USB support from the future releases to the Gen3 features, with
what it covers now and what it doesn't yet (USB mouse, EHCI). Drop the
first-release percentage badge.
@valentinbreiz
valentinbreiz merged commit fec2ccb into feature/usb-mass-storage Sep 24, 2026
26 checks passed
@valentinbreiz
valentinbreiz deleted the feature/usb-hotplug branch September 24, 2026 08:06
@github-actions

Copy link
Copy Markdown

🧪 GarbageCollector Tests

Cell x64 arm64
all ✅ 47/47 ✅ 47/47

📎 Artifacts

Architecture Test Results UART Log Kernel ISO
x64 XML Log ISO
arm64 XML Log ISO

📋 View full test summary | 📄 Kernel.cs

@github-actions

Copy link
Copy Markdown

🧪 TypeCasting Tests

Cell x64 arm64
all ✅ 17/17 ✅ 17/17

📎 Artifacts

Architecture Test Results UART Log Kernel ISO
x64 XML Log ISO
arm64 XML Log ISO

📋 View full test summary | 📄 Kernel.cs

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.

1 participant