Skip to content

usb.core: add raise_on_timeout=False option to Device.read - #11137

Open
lynt-smitka wants to merge 1 commit into
adafruit:mainfrom
MakerClassCZ:usb-read-no-raise
Open

usb.core: add raise_on_timeout=False option to Device.read#11137
lynt-smitka wants to merge 1 commit into
adafruit:mainfrom
MakerClassCZ:usb-read-no-raise

Conversation

@lynt-smitka

Copy link
Copy Markdown

Polling an interrupt IN endpoint every frame, a USB HID gamepad in a game loop on a USB-host board like the Fruit Jam, treats "no new report yet" as a completely normal outcome. Today Device.read() can only express that outcome by raising usb.core.USBTimeoutError, and every raise allocates an exception instance.

The cost is measurable: in a 30 fps game polling one gamepad, these exceptions alone produced ~1-1.5 KB/s of transient garbage (RP2350 Fruit Jam) - more than everything else in the game loop combined. That churn eventually surfaces as GC pauses, visible as dropped frames mid-game.

This PR adds a keyword-only raise_on_timeout: bool = True to Device.read(). The default keeps the pyusb-compatible raising behavior; with raise_on_timeout=False an elapsed timeout returns 0, so a per-frame poll allocates nothing. Both timeout paths (TinyUSB-reported and the CircuitPython-side deadline) behave the same; transfer abort and buffer cleanup are unchanged, as are write() and ctrl_transfer().

Tested: compiled for adafruit_fruit_jam; exercised on hardware by a game polling a "USB Game Controller with SNES-like Layout" per frame (0 bytes returned on empty polls, reports parsed normally, unplug still releases all buttons).

Possible follow-up: a persistent interrupt transfer - submit once, harvest completions across calls - so per-frame polling doesn't have to submit-and-abort a transfer each time.

Polling an interrupt IN endpoint every frame (a HID gamepad in a game
loop) treats "no new report yet" as a normal outcome, but read() can
only express it by raising USBTimeoutError. Each raise allocates an
exception instance, which adds up to measurable GC pressure on small
heaps: a 30 fps game polling one gamepad measured ~1-1.5 KB/s of
transient garbage from these exceptions alone (RP2350 Fruit Jam).

With raise_on_timeout=False (keyword-only, default True keeps the
pyusb-compatible behavior), an elapsed timeout returns 0 instead of
raising, so a per-frame poll allocates nothing. A timeout can be
detected either by TinyUSB or by CircuitPython's own deadline; with
the flag set, both paths return 0 after the usual transfer abort and
buffer cleanup, instead of raising.

@tannewt tannewt left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Sounds good! Thanks!

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.

2 participants