From 68bad810828a88edf2dfd4cf9f7837f06e87eab6 Mon Sep 17 00:00:00 2001 From: HRS Date: Fri, 7 Aug 2026 17:19:30 +0200 Subject: [PATCH] fix: only log USB event once per debounce window in SerialPortObserver Duplicate log lines were seen for a single physical USB plug event because the underlying usb lib can fire multiple native events (e.g. connect twice for a composite USB-serial adapter) close together. The log statement fired on every raw event, before the debounce timer had a chance to collapse them, even though only one actual scan ran. Now the log only fires when opening a new debounce window (i.e. when no rescan is already pending), and rescanTimer is reset once the scan runs so a later, separate USB event still logs correctly. --- src/device/transport/serialPortObserver.ts | 8 ++-- .../transport/serialPortObserver.spec.ts | 40 +++++++++++++++++++ 2 files changed, 45 insertions(+), 3 deletions(-) diff --git a/src/device/transport/serialPortObserver.ts b/src/device/transport/serialPortObserver.ts index 4e74d77..642aab8 100644 --- a/src/device/transport/serialPortObserver.ts +++ b/src/device/transport/serialPortObserver.ts @@ -39,13 +39,15 @@ export default class SerialPortObserver extends SharedObserver await this.discoverSerialDevices(); this.onUsbEventRef = (): void => { - this.logger.debug('USB event detected, scanning for serial devices in 1s...'); - - if (this.rescanTimer !== undefined) { + if (this.rescanTimer === undefined) { + this.logger.debug('USB event detected, scanning for serial devices in 1s...'); + } else { clearTimeout(this.rescanTimer); } this.rescanTimer = setTimeout(() => { + this.rescanTimer = undefined; + this.discoveryQueue.run((cancellationToken) => this.discoverSerialDevices(cancellationToken)) .catch((e: unknown) => { if (e === cancellationTokenReasons.cancel) { diff --git a/tests/unit/device/transport/serialPortObserver.spec.ts b/tests/unit/device/transport/serialPortObserver.spec.ts index e197ccf..90b8272 100644 --- a/tests/unit/device/transport/serialPortObserver.spec.ts +++ b/tests/unit/device/transport/serialPortObserver.spec.ts @@ -281,6 +281,46 @@ describe('SerialPortObserver', () => { }); }); + describe('USB event debounce logging', () => { + function getRegisteredUsbEventHandler(): () => void { + const call = mockUsb.addEventListener.mock.calls.find(([event]) => event === 'connect'); + + return call?.[1] as () => void; + } + + it('logs the USB event message only once when multiple raw USB events fire within the same debounce window', async () => { + vi.spyOn(SerialPort, 'list').mockResolvedValue([]); + const observer = createObserver(); + await observer.start(); + + const onUsbEvent = getRegisteredUsbEventHandler(); + + // Simulates the underlying usb lib firing two native events for one physical + // plug action (observed e.g. with composite USB-serial adapters on macOS) + onUsbEvent(); + onUsbEvent(); + + expect(mockLogger.debug).toHaveBeenCalledWith('USB event detected, scanning for serial devices in 1s...'); + expect(mockLogger.debug).toHaveBeenCalledTimes(1); + }); + + it('logs the USB event message again for a later, separate USB event once the previous scan has run', async () => { + vi.spyOn(SerialPort, 'list').mockResolvedValue([]); + const observer = createObserver(); + await observer.start(); + + const onUsbEvent = getRegisteredUsbEventHandler(); + + onUsbEvent(); + await vi.advanceTimersByTimeAsync(1000); + + onUsbEvent(); + + expect(mockLogger.debug).toHaveBeenCalledWith('USB event detected, scanning for serial devices in 1s...'); + expect(mockLogger.debug).toHaveBeenCalledTimes(2); + }); + }); + describe('restart after full stop (e.g. device source disabled then re-enabled)', () => { it('re-announces a still-plugged-in device after the observer was fully stopped and started again', async () => { const port = makePortInfo({ path: '/dev/ttyUSB0', serialNumber: 'SN001', vendorId: '0403', productId: '6001' });