diff --git a/.changeset/silver-llamas-fly.md b/.changeset/silver-llamas-fly.md new file mode 100644 index 0000000..861510e --- /dev/null +++ b/.changeset/silver-llamas-fly.md @@ -0,0 +1,5 @@ +--- +"@webnect/driver": patch +--- + +Fix Linux Chromium iso transfer crash on `Camera.setMode`. diff --git a/driver/src/camera/stream/enum.ts b/driver/src/camera/stream/enum.ts index b5a4b07..7b0abde 100644 --- a/driver/src/camera/stream/enum.ts +++ b/driver/src/camera/stream/enum.ts @@ -15,6 +15,10 @@ export enum CamIsoInterface { CAMERA = 0, } +export enum CamIsoAltSetting { + CAMERA = 0, +} + export enum CamIsoStreamFlag { VIDEO = 0b1000, DEPTH = 0b0111, diff --git a/driver/src/camera/worker/get-device.ts b/driver/src/camera/worker/get-device.ts new file mode 100644 index 0000000..07d2a50 --- /dev/null +++ b/driver/src/camera/worker/get-device.ts @@ -0,0 +1,27 @@ +import { CamIsoAltSetting, type CamIsoInterface } from "../stream/enum.js"; + +export async function prepareCamDevice( + dev: USBDevice, + usbInterface: CamIsoInterface, +): Promise { + if (!dev.opened) { + await dev.open(); + } + + const iface = dev.configuration?.interfaces.find( + (i) => i.interfaceNumber === usbInterface, + ); + if (!iface) { + throw new ReferenceError(`Interface ${usbInterface} not found`); + } + + if (!iface.claimed) { + await dev.claimInterface(usbInterface); + } + + if (iface.alternate.alternateSetting !== CamIsoAltSetting.CAMERA) { + await dev.selectAlternateInterface(usbInterface, CamIsoAltSetting.CAMERA); + } + + return dev; +} diff --git a/driver/src/camera/worker/worker.ts b/driver/src/camera/worker/worker.ts index 987921e..fefaaac 100644 --- a/driver/src/camera/worker/worker.ts +++ b/driver/src/camera/worker/worker.ts @@ -9,6 +9,7 @@ import { import { CamIsoStream } from "../stream/iso-parser.js"; import { UnderlyingIsochronousTransferSource } from "../stream/transfer-source.js"; import { ISOCHRONOUS_BATCH_SIZE } from "./constants.js"; +import { prepareCamDevice } from "./get-device.js"; import { type IsoWorkerRequest, type IsoWorkerResponse, @@ -40,24 +41,7 @@ const getDevice = async ( `Device with serial number ${serialNumber} not found`, ); } - - if (!dev.opened) { - await dev.open(); - } - - const iface = dev.configuration?.interfaces.find( - (iface) => iface.interfaceNumber === usbInterface, - ); - - if (!iface) { - throw new ReferenceError(`Interface ${usbInterface} not found`); - } - - if (!iface.claimed) { - await dev.claimInterface(usbInterface); - } - - return dev; + return prepareCamDevice(dev, usbInterface); }); return activeDevice; diff --git a/driver/test/prepare-cam-device.test.ts b/driver/test/prepare-cam-device.test.ts new file mode 100644 index 0000000..1a2176d --- /dev/null +++ b/driver/test/prepare-cam-device.test.ts @@ -0,0 +1,144 @@ +/// +/** biome-ignore-all lint/style/noNonNullAssertion: test code */ + +import { describe, expect, test, vi } from "vitest"; +import { + CamIsoAltSetting, + CamIsoInterface, +} from "../src/camera/stream/enum.js"; +import { prepareCamDevice } from "../src/camera/worker/get-device.js"; + +const makeAlt = (alternateSetting: number): USBAlternateInterface => + ({ + alternateSetting, + interfaceClass: 0xff, + interfaceSubclass: 0, + interfaceProtocol: 0, + interfaceName: null, + endpoints: [], + }) as unknown as USBAlternateInterface; + +const makeIface = ( + interfaceNumber: number, + { claimed = false, alternateSetting = 0 } = {}, +): USBInterface => { + const alt = makeAlt(alternateSetting); + return { + interfaceNumber, + alternate: alt, + alternates: [alt], + claimed, + } as unknown as USBInterface; +}; + +const makeDevice = ( + iface: USBInterface, + { opened = true } = {}, +): { + device: USBDevice; + open: ReturnType>; + claimInterface: ReturnType>; + selectAlternateInterface: ReturnType< + typeof vi.fn + >; +} => { + const open = vi.fn().mockResolvedValue(undefined); + const claimInterface = vi + .fn() + .mockResolvedValue(undefined); + const selectAlternateInterface = vi + .fn() + .mockResolvedValue(undefined); + const device: Partial = { + opened, + configuration: { + interfaces: [iface], + } as unknown as USBConfiguration, + open, + claimInterface, + selectAlternateInterface, + }; + return { + device: device as USBDevice, + open, + claimInterface, + selectAlternateInterface, + }; +}; + +describe("prepareCamDevice", () => { + test("claims then selects target alt when interface is unclaimed and on a different alt", async () => { + const iface = makeIface(CamIsoInterface.CAMERA, { + claimed: false, + alternateSetting: 1, + }); + const { device, claimInterface, selectAlternateInterface } = + makeDevice(iface); + + await prepareCamDevice(device, CamIsoInterface.CAMERA); + + expect(claimInterface).toHaveBeenCalledWith(CamIsoInterface.CAMERA); + expect(selectAlternateInterface).toHaveBeenCalledWith( + CamIsoInterface.CAMERA, + CamIsoAltSetting.CAMERA, + ); + + const claimOrder = claimInterface.mock.invocationCallOrder[0]!; + const selectOrder = selectAlternateInterface.mock.invocationCallOrder[0]!; + expect(claimOrder).toBeLessThan(selectOrder); + }); + + test("skips claim when already claimed but still selects target alt if currently on a different alt", async () => { + const iface = makeIface(CamIsoInterface.CAMERA, { + claimed: true, + alternateSetting: 1, + }); + const { device, claimInterface, selectAlternateInterface } = + makeDevice(iface); + + await prepareCamDevice(device, CamIsoInterface.CAMERA); + + expect(claimInterface).not.toHaveBeenCalled(); + expect(selectAlternateInterface).toHaveBeenCalledWith( + CamIsoInterface.CAMERA, + CamIsoAltSetting.CAMERA, + ); + }); + + test("skips selectAlternateInterface when device is already on the target alt", async () => { + const iface = makeIface(CamIsoInterface.CAMERA, { + claimed: false, + alternateSetting: CamIsoAltSetting.CAMERA, + }); + const { device, claimInterface, selectAlternateInterface } = + makeDevice(iface); + + await prepareCamDevice(device, CamIsoInterface.CAMERA); + + expect(claimInterface).toHaveBeenCalledWith(CamIsoInterface.CAMERA); + expect(selectAlternateInterface).not.toHaveBeenCalled(); + }); + + test("throws ReferenceError when the requested interface is not present", async () => { + const iface = makeIface(7); + const { device } = makeDevice(iface); + + await expect( + prepareCamDevice(device, CamIsoInterface.CAMERA), + ).rejects.toBeInstanceOf(ReferenceError); + }); + + test("opens the device before claiming when not yet open", async () => { + const iface = makeIface(CamIsoInterface.CAMERA, { claimed: false }); + const { device, open, claimInterface } = makeDevice(iface, { + opened: false, + }); + + await prepareCamDevice(device, CamIsoInterface.CAMERA); + + expect(open).toHaveBeenCalled(); + const openOrder = open.mock.invocationCallOrder[0]!; + const claimOrder = claimInterface.mock.invocationCallOrder[0]!; + expect(openOrder).toBeLessThan(claimOrder); + }); +});