fix: reset USB gadget when virtual media unmount fails with EBUSY (#834)

When unmountImageLocked() gets EBUSY from the kernel (host OS still
accessing the virtual disk via PREVENT MEDIUM REMOVAL), fall back to
gadget.RebindUsb(true) to force-disconnect the host, then retry the
unmount. Uses RebindUsb directly instead of UpdateGadgetConfig to avoid
hitting the same EBUSY when writing configfs attributes before rebind.

After rebind, properly reopen keyboard HID file (ResetHIDFiles + sleep +
OpenKeyboardHidFile) matching the pattern in setMassStorageMode().

Also propagate unmount errors to RPC callers and only clear
currentVirtualMediaState after the unmount actually succeeds.

Adds E2E test that mounts an ISO on the remote host to trigger PREVENT
MEDIUM REMOVAL, then verifies unmount succeeds and keyboard recovers.
This commit is contained in:
Adam Shiervani
2026-03-28 15:26:52 +01:00
parent 12d0a91034
commit b9ff5c9def
2 changed files with 173 additions and 15 deletions
+145 -11
View File
@@ -271,7 +271,6 @@ async function waitForRpcReady(page: Page, timeoutMs = 30000) {
}
}
throw new Error(`RPC channel not ready after ${timeoutMs}ms`);
}
test.beforeAll(async ({ browser }) => {
@@ -308,11 +307,17 @@ test.beforeAll(async ({ browser }) => {
const kbDeadline = Date.now() + 15000;
while (Date.now() < kbDeadline) {
try {
await agent!.expectKeyPress(KEY.SPACE, async () => {
await tapKey(sharedPage, HID_KEY.SPACE);
}, 3000);
await agent!.expectKeyPress(
KEY.SPACE,
async () => {
await tapKey(sharedPage, HID_KEY.SPACE);
},
3000,
);
break;
} catch { /* not ready yet */ }
} catch {
/* not ready yet */
}
}
});
@@ -1022,6 +1027,131 @@ test.describe("Remote Host Agent", () => {
expect(postUnmountEvents.length, "keyboard should work after unmount").toBeGreaterThan(0);
});
// ═══════════════════════════════════════════
// VIRTUAL MEDIA: EBUSY UNMOUNT FALLBACK (#834)
// ═══════════════════════════════════════════
test("virtual-media: unmount succeeds when host holds device open via EBUSY fallback (#834)", async () => {
test.setTimeout(120_000);
// Ensure clean state
try {
await callJsonRpc(sharedPage, "unmountImage");
} catch {
/* ok if nothing mounted */
}
// Verify keyboard works before test
const preEvents = await agent!.expectKeyPress(KEY.SPACE, async () => {
await tapKey(sharedPage, HID_KEY.SPACE);
});
expect(preEvents.length, "keyboard should work before EBUSY test").toBeGreaterThan(0);
// Mount ISO as CDROM
const NETBOOT_XYZ_URL = "https://boot.netboot.xyz/ipxe/netboot.xyz.iso";
await callJsonRpc(sharedPage, "mountWithHTTP", { url: NETBOOT_XYZ_URL, mode: "CDROM" });
const vmState = (await callJsonRpc(sharedPage, "getVirtualMediaState")) as {
source: string;
mode: string;
} | null;
expect(vmState).not.toBeNull();
expect(vmState!.mode).toBe("CDROM");
// Wait for the host to enumerate the USB mass storage device
await new Promise(r => setTimeout(r, 5000));
// Find the JetKVM CDROM block device on the remote host by matching USB VID:PID
const findBlockDevCmd =
`for sr in /sys/class/block/sr*; do ` +
`[ -e "$sr" ] || continue; ` +
`dev=$(basename "$sr"); ` +
`p=$(readlink -f "$sr/device"); ` +
`while [ "$p" != "/" ] && [ -n "$p" ]; do ` +
`if [ -f "$p/idVendor" ] && [ -f "$p/idProduct" ]; then ` +
`v=$(cat "$p/idVendor"); ` +
`pid=$(cat "$p/idProduct"); ` +
`if [ "$v" = "1d6b" ] && [ "$pid" = "0104" ]; then ` +
`echo "/dev/$dev"; exit 0; fi; break; fi; ` +
`p=$(dirname "$p"); done; done`;
let blockDev = "";
const devDeadline = Date.now() + 15000;
while (Date.now() < devDeadline) {
try {
blockDev = remoteHostExec(findBlockDevCmd).trim();
if (blockDev) break;
} catch {
/* retry */
}
await new Promise(r => setTimeout(r, 1000));
}
expect(blockDev, "JetKVM CDROM block device should appear on host").not.toBe("");
// Mount the ISO on the remote host — triggers PREVENT MEDIUM REMOVAL SCSI command,
// which causes the KVM kernel to return EBUSY when clearing the backing file.
const mountPoint = "/tmp/jetkvm-e2e-cdrom";
try {
remoteHostExec(`sudo mkdir -p ${mountPoint} && sudo mount -o ro ${blockDev} ${mountPoint}`);
} catch {
// If ISO mount fails, try eject -i on as fallback to lock the medium
try {
remoteHostExec(`sudo eject -i on ${blockDev}`);
} catch {
test.skip(true, "Could not lock CDROM medium on remote host");
return;
}
}
try {
// Unmount on the KVM side — should hit EBUSY, then fallback rebinds USB
await callJsonRpc(sharedPage, "unmountImage");
// Verify virtual media state is cleared
const stateEnd = (await callJsonRpc(sharedPage, "getVirtualMediaState")) as null | object;
expect(stateEnd, "Virtual media should be unmounted after EBUSY fallback").toBeNull();
// Wait for HID devices to re-enumerate after the USB rebind
await agent!.waitForInputDevices(["keyboard", "absolute_mouse", "relative_mouse"], 15000);
// Verify keyboard still works after the rebind
const postDeadline = Date.now() + 30000;
let postEvents: RAKeyboardEvent[] = [];
while (Date.now() < postDeadline) {
try {
postEvents = await agent!.expectKeyPress(
KEY.SPACE,
async () => {
await tapKey(sharedPage, HID_KEY.SPACE);
},
3000,
);
break;
} catch {
/* retry */
}
}
expect(
postEvents.length,
"keyboard should work after EBUSY unmount fallback",
).toBeGreaterThan(0);
} finally {
// Clean up: unmount on remote host (may already be ejected by USB rebind)
try {
remoteHostExec(
`sudo umount -l ${mountPoint} 2>/dev/null; sudo rmdir ${mountPoint} 2>/dev/null`,
);
} catch {
/* best effort */
}
try {
remoteHostExec(`sudo eject -i off ${blockDev} 2>/dev/null`);
} catch {
/* best effort */
}
}
});
// ═══════════════════════════════════════════
// USB: DEVICE PRESENCE + SWITCHING + DESCRIPTORS
// ═══════════════════════════════════════════
@@ -1392,17 +1522,21 @@ test.describe("Remote Host Agent", () => {
// Store a device with custom broadcast IP via RPC
await callJsonRpc(sharedPage, "setWakeOnLanDevices", {
params: {
devices: [{
name: "E2E Broadcast Test",
macAddress: "AA:BB:CC:DD:EE:FF",
broadcastIP: "10.0.0.255",
}],
devices: [
{
name: "E2E Broadcast Test",
macAddress: "AA:BB:CC:DD:EE:FF",
broadcastIP: "10.0.0.255",
},
],
},
});
// Read it back and verify broadcastIP was persisted
const devices = (await callJsonRpc(sharedPage, "getWakeOnLanDevices")) as {
name: string; macAddress: string; broadcastIP?: string;
name: string;
macAddress: string;
broadcastIP?: string;
}[];
expect(devices.length).toBe(1);
expect(devices[0].broadcastIP).toBe("10.0.0.255");
+28 -4
View File
@@ -209,24 +209,48 @@ func rpcGetVirtualMediaState() (*VirtualMediaState, error) {
return currentVirtualMediaState, nil
}
func unmountImageLocked() {
func unmountImageLocked() error {
virtualMediaStateMutex.Lock()
defer virtualMediaStateMutex.Unlock()
err := setMassStorageImage("\n")
if err != nil {
logger.Warn().Err(err).Msg("Remove Mass Storage Image Error")
if !errors.Is(err, syscall.EBUSY) {
return fmt.Errorf("failed to unmount image: %w", err)
}
logger.Warn().Err(err).Msg("unmount failed with EBUSY, rebinding USB gadget to force-eject")
if rebindErr := gadget.RebindUsb(true); rebindErr != nil {
return fmt.Errorf("failed to unmount image: %w, gadget rebind also failed: %w", err, rebindErr)
}
gadget.ResetHIDFiles()
time.Sleep(1 * time.Second)
if openErr := gadget.OpenKeyboardHidFile(); openErr != nil {
logger.Warn().Err(openErr).Msg("failed to reopen keyboard HID file after EBUSY unmount rebind")
}
if retryErr := setMassStorageImage("\n"); retryErr != nil {
return fmt.Errorf("failed to unmount image after gadget rebind: %w", retryErr)
}
}
//TODO: check if we still need it
time.Sleep(500 * time.Millisecond)
if nbdDevice != nil {
nbdDevice.Close()
nbdDevice = nil
}
currentVirtualMediaState = nil
return nil
}
func rpcUnmountImage() error {
unmountImageLocked()
if err := unmountImageLocked(); err != nil {
return err
}
if mqttManager != nil {
mqttManager.publishVirtualMediaState()
}