From b9ff5c9def715ad8add6f25bf1ec582ab1cf7671 Mon Sep 17 00:00:00 2001 From: Adam Shiervani Date: Sat, 28 Mar 2026 15:26:52 +0100 Subject: [PATCH] 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. --- ui/e2e/remote-agent/ra-all.spec.ts | 156 +++++++++++++++++++++++++++-- usb_mass_storage.go | 32 +++++- 2 files changed, 173 insertions(+), 15 deletions(-) diff --git a/ui/e2e/remote-agent/ra-all.spec.ts b/ui/e2e/remote-agent/ra-all.spec.ts index 97397ae6..6670b44e 100644 --- a/ui/e2e/remote-agent/ra-all.spec.ts +++ b/ui/e2e/remote-agent/ra-all.spec.ts @@ -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"); diff --git a/usb_mass_storage.go b/usb_mass_storage.go index 3612dc39..909bad2b 100644 --- a/usb_mass_storage.go +++ b/usb_mass_storage.go @@ -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() }