Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

usb: Fix USB device library transfer thread safety #896

Merged
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 12 additions & 0 deletions micropython/usb/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -134,3 +134,15 @@ USB MIDI devices in MicroPython.

The example [midi_example.py](examples/device/midi_example.py) demonstrates how
to create a simple MIDI device to send MIDI data to and from the USB host.

### Limitations

#### Buffer thread safety

The internal Buffer class that's used by most of the USB device classes expects data
to be written to it (i.e. sent to the host) by only one thread. Bytes may be
lost from the USB transfers if more than one thread (or a thread and a callback)
try to write to the buffer simultaneously.

If writing USB data from multiple sources, your code may need to add
synchronisation (i.e. locks).
2 changes: 1 addition & 1 deletion micropython/usb/usb-device/manifest.py
Original file line number Diff line number Diff line change
@@ -1,2 +1,2 @@
metadata(version="0.1.0")
metadata(version="0.1.1")
package("usb")
52 changes: 42 additions & 10 deletions micropython/usb/usb-device/usb/device/core.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,14 @@
import machine
import struct

try:
from _thread import get_ident
except ImportError:

def get_ident():
return 0 # Placeholder, for no threading support


_EP_IN_FLAG = const(1 << 7)

# USB descriptor types
Expand Down Expand Up @@ -76,6 +84,8 @@ def __init__(self):
self._itfs = {} # Mapping from interface number to interface object, set by init()
self._eps = {} # Mapping from endpoint address to interface object, set by _open_cb()
self._ep_cbs = {} # Mapping from endpoint address to Optional[xfer callback]
self._cb_thread = None # Thread currently running endpoint callback
self._cb_ep = None # Endpoint number currently running callback
self._usbd = machine.USBDevice() # low-level API

def init(self, *itfs, **kwargs):
Expand Down Expand Up @@ -242,8 +252,8 @@ def active(self, *optional_value):
return self._usbd.active(*optional_value)

def _open_itf_cb(self, desc):
# Singleton callback from TinyUSB custom class driver, when USB host does
# Set Configuration. Called once per interface or IAD.
# Callback from TinyUSB lower layer, when USB host does Set
# Configuration. Called once per interface or IAD.

# Note that even if the configuration descriptor contains an IAD, 'desc'
# starts from the first interface descriptor in the IAD and not the IAD
Expand Down Expand Up @@ -281,7 +291,7 @@ def _open_itf_cb(self, desc):
itf.on_open()

def _reset_cb(self):
# Callback when the USB device is reset by the host
# TinyUSB lower layer callback when the USB device is reset by the host

# Allow interfaces to respond to the reset
for itf in self._itfs.values():
Expand All @@ -292,13 +302,13 @@ def _reset_cb(self):
self._ep_cbs = {}

def _submit_xfer(self, ep_addr, data, done_cb=None):
# Singleton function to submit a USB transfer (of any type except control).
# Submit a USB transfer (of any type except control) to TinyUSB lower layer.
#
# Generally, drivers should call Interface.submit_xfer() instead. See
# that function for documentation about the possible parameter values.
if ep_addr not in self._eps:
raise ValueError("ep_addr")
if self._ep_cbs[ep_addr]:
if self._xfer_pending(ep_addr):
raise RuntimeError("xfer_pending")

# USBDevice callback may be called immediately, before Python execution
Expand All @@ -308,15 +318,32 @@ def _submit_xfer(self, ep_addr, data, done_cb=None):
self._ep_cbs[ep_addr] = done_cb or True
return self._usbd.submit_xfer(ep_addr, data)

def _xfer_pending(self, ep_addr):
# Returns True if a transfer is pending on this endpoint.
#
# Generally, drivers should call Interface.xfer_pending() instead. See that
# function for more documentation.
return self._ep_cbs[ep_addr] or (self._cb_ep == ep_addr and self._cb_thread != get_ident())

def _xfer_cb(self, ep_addr, result, xferred_bytes):
# Singleton callback from TinyUSB custom class driver when a transfer completes.
# Callback from TinyUSB lower layer when a transfer completes.
cb = self._ep_cbs.get(ep_addr, None)
self._cb_thread = get_ident()
self._cb_ep = ep_addr # Track while callback is running
self._ep_cbs[ep_addr] = None
if callable(cb):
cb(ep_addr, result, xferred_bytes)

# In most cases, 'cb' is a callback function for the transfer. Can also be:
# - True (for a transfer with no callback)
# - None (TinyUSB callback arrived for invalid endpoint, or no transfer.
# Generally unlikely, but may happen in transient states.)
try:
if callable(cb):
Copy link
Member

Choose a reason for hiding this comment

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

Is this check for callable really necessary? Would a check against None be sufficient? That would be more efficient.

Then maybe even:

if cb:
    self._cb_thread = get_ident()
    self._cb_ep = ep_addr  # Track while callback is running
    try:
        cb(...)
    finally:
        self._cb_ep = None

Copy link
Contributor Author

Choose a reason for hiding this comment

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

The value of cb should be True if a transfer is pending but no callback is set (so it can be tested for truthiness elsewhere), or will be the callback function otherwise.

Have put in a comment to make this clearer.

(As an aside, this is the kind of thing that typing annotations really help with!)

Copy link
Member

Choose a reason for hiding this comment

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

Won't the cb be None in some cases, due to the get(ep_addr, None) above?

I was also thinking that the try/finally (which is expensive) only needs to be done if the cb is going to be called.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Won't the cb be None in some cases, due to the get(ep_addr, None) above?

If it's None here then it's some kind of semi-error case because TinyUSB is giving us a callback for a transfer we didn't initiate. It might happen if the device configuration is being changed, but it's not something which happens normally..

I was also thinking that the try/finally (which is expensive) only needs to be done if the cb is going to be called.

Oh,right. I guess there's a version of this which is:

if callable(cb):
    try: 
        ...

I think 90% of cases (and especially performance critical cases) there will be a callback here, though.

I'll update the comment again, at least.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

(Updated, realised moving the if outside the try...finally block is fiddly because we always want the code in the finally to run.)

Copy link
Member

Choose a reason for hiding this comment

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

I guess you could also do if not callable(cb): return near the start of this function, to avoid most of the work if there's no callback. But if the callback is there in a large proportion of cases, then the way it is now is fine.

cb(ep_addr, result, xferred_bytes)
finally:
self._cb_ep = None

def _control_xfer_cb(self, stage, request):
# Singleton callback from TinyUSB custom class driver when a control
# Callback from TinyUSB lower layer when a control
# transfer is in progress.
#
# stage determines appropriate responses (possible values
Expand Down Expand Up @@ -528,7 +555,12 @@ def xfer_pending(self, ep_addr):
# Return True if a transfer is already pending on ep_addr.
#
# Only one transfer can be submitted at a time.
return _dev and bool(_dev._ep_cbs[ep_addr])
#
# The transfer is marked pending while a completion callback is running
# for that endpoint, unless this function is called from the callback
# itself. This makes it simple to submit a new transfer from the
# completion callback.
return _dev and _dev._xfer_pending(ep_addr)

def submit_xfer(self, ep_addr, data, done_cb=None):
# Submit a USB transfer (of any type except control)
Expand Down
Loading