Skip to content

supervisor: keep CIRCUITPY writable after a USB bus reset - #11745

Merged
dhalbert merged 5 commits into
adafruit:mainfrom
vegardw:usb-msc-stale-lock
Nov 12, 2026
Merged

dhalbert merged 5 commits into
adafruit:mainfrom
vegardw:usb-msc-stale-lock

Conversation

@vegardw

@vegardw vegardw commented Nov 4, 2026 •

Copy link
Copy Markdown

I found this while porting CircuitPython to the ELECFREAKS Retro Arcade, a MakeCode Arcade handheld with an STM32F412. The port itself and a few more fixes will follow as separate PRs. All of them have been tested on that hardware.

Problem

When the host resets the USB bus without the board seeing an unplug, CIRCUITPY comes back write-protected. It stays that way until a hard reset: remounting and soft reloads don't help. Hosts reset the bus like this after sleep, when a powered hub keeps the board powered while the computer restarts (#10149), and when a VM attaches the device.

tud_msc_is_writable_cb() takes the blockdev lock on the first writable query and records it in locked[lun]. The lock is released by usb_msc_umount() or an eject. But TinyUSB only calls tud_umount_cb() on an unplug or SET_CONFIGURATION(0). A bus reset only resets the class drivers. The SET_CONFIGURATION that ends the next enumeration calls tud_mount_cb(), and _usb_msc_uneject() then cleared locked[] while MP_BLOCKDEV_FLAG_LOCKED stayed set. From then on, blockdev_lock() fails and every writable query returns false.

The lock tracking came in with #8699, which fits #10149's report that 9.0.0-alpha.6 was good and 9.0.0-beta.0 bad.

Fix

locked[lun] records that USB MSC holds the blockdev lock. The four commits make every USB MSC path follow that record:

  • Bus reset (commits 1 and 3): _usb_msc_uneject() no longer clears locked[]. If USB MSC held the lock before the reset, it still holds it, and the next tud_msc_is_writable_cb() sees that and doesn't lock again. Commit 1 released the lock and let the next writable query take it again; commit 3, from the review, keeps it instead, so Python or a workflow can't take the drive in between.
  • Unplug or SET_CONFIGURATION(0) (commit 2): usb_msc_umount() releases only the locks that USB MSC holds, so a file that Python has open for writing keeps its lock.
  • Eject (commit 4, from the review): the eject in tud_msc_start_stop_cb() releases the lock only if USB MSC holds it.

Locks held by Python, the web workflow or BLE are never released by USB MSC.

Testing

ELECFREAKS Retro Arcade (STM32F412, no VBUS sensing) on macOS. IOKit's USBDeviceReEnumerate() resets the bus with VBUS on.

  • Without this PR: the first reset left CIRCUITPY write-protected ("Media Read-Only: Yes"). A build with counters showed the sequence: one more bus reset and mount, no umount, and locked[0] back at 0 while the blockdev flag stayed set.
  • With this PR: CIRCUITPY stays writable after a reset with the drive unmounted, with it mounted, and after an eject. storage.remount("/", readonly=False) is still refused afterwards, so USB holds the lock again as it should.
  • Windows 11, with With powered hub, RP2040/RP2350 MCU file system becomes read only when PC turned off/on. #10149's steps: a second Retro Arcade, on a powered hub, running the port without any of my other fixes.
    • Without this PR: CIRCUITPY was writable before a Windows restart and write-protected after it.
    • With this PR: it stayed writable through the restart.
  • The second commit, with a file open for writing (steps in the review discussion), on macOS (IOKit SetConfiguration(0), then a re-enumeration) and on Linux (sysfs bConfigurationValue 0, then 1):
    • First commit only: CIRCUITPY came back writable while the file was open.
    • Both commits: it stayed read-only.
  • All four commits, on Linux (Debian 13): bus reset (usbreset), bConfigurationValue 0 → 1, and eject (sg_start --readonly --eject), with and without a file open for writing. The table is in the review discussion. Without the PR, the bus-reset and file-open cases fail; with it, all of them pass.
  • In normal use: the fault first happened when the Mac slept and woke, which is how I found it.

Not tested on other ports. The web workflow and BLE file transfer aren't tested either: the Retro Arcade has neither. Trying #10149's steps on an RP2040 board would be welcome.

How to run the test

Files: msc_reset_test.py and usb_reenumerate.c, also attached at the end of this description. You need a board running CircuitPython with CIRCUITPY mounted, and Python 3; no packages are needed. The script writes a test file to CIRCUITPY, makes the host reset the board's USB connection without unplugging it, and writes again.

macOS (tested):

xcode-select --install     # only if clang isn't installed yet
clang -framework IOKit -framework CoreFoundation usb_reenumerate.c -o usb_reenumerate
system_profiler SPUSBHostDataType     # find the board by its product name; note "USB Vendor ID" and "USB Product ID"
python3 msc_reset_test.py --reset-command "./usb_reenumerate VID PID"

VID PID are the board's IDs in hex, for example ./usb_reenumerate 239a 80cf for a Meowbit. They are also in the board's mpconfigboard.mk, as USB_VID and USB_PID. usb_reenumerate calls IOKit's USBDeviceReEnumerate().

Linux (tested on Debian 13):

lsusb                      # find the board's ID, for example 239a:80cf
python3 msc_reset_test.py --reset-command "sudo usbreset VID:PID"

usbreset is part of usbutils.

Windows (tested on Windows 11, with and without this PR): there is no simple reset command. Use #10149's steps instead:

  1. Put the board on a powered USB hub.
  2. Check that CIRCUITPY is writable: py msc_reset_test.py --check.
  3. Restart Windows. The hub keeps the board powered, so the board sees a bus reset but no unplug.
  4. Check again: py msc_reset_test.py --check.

Another way: run py msc_reset_test.py with no options. When it asks, put the PC to sleep and wake it again, then press Enter. A sleep/wake only sometimes resets the bus, so it may take a few tries.

Output without this PR (Windows 11, PowerShell, CIRCUITPY on F:):

PS C:\Users\…\Documents> py F:\msc_reset_test.py --check
OK: F:\ is writable.

(restart Windows)

PS C:\Users\…\Documents> py F:\msc_reset_test.py --check
FAIL: F:\ is write-protected ([Errno 13] Permission denied: 'F:\\msc_reset_test.txt').

Output with this PR (same set-up):

PS C:\Users\…\Documents> py F:\msc_reset_test.py --check
OK: F:\ is writable.

(restart Windows)

PS C:\Users\…\Documents> py F:\msc_reset_test.py --check
OK: F:\ is writable.

These steps also work on macOS and Linux.

Output with this PR (macOS, Retro Arcade):

Before the reset: /Volumes/CIRCUITPY is writable.
Running: ./usb_reenumerate 1209 0001
USBDeviceOpen: 0x0
USBDeviceReEnumerate: 0x0 (ok)
PASS: after the reset, /Volumes/CIRCUITPY is still writable.

Output without it:

Before the reset: /Volumes/CIRCUITPY is writable.
Running: ./usb_reenumerate 1209 0001
USBDeviceOpen: 0x0
USBDeviceReEnumerate: 0x0 (ok)
FAIL: after the reset, /Volumes/CIRCUITPY is write-protected ([Errno 30] Read-only file system: '/Volumes/CIRCUITPY/msc_reset_test.txt').

After a FAIL, the drive stays read-only until the board is hard-reset.

The same size on the Retro Arcade as without this PR.

Trade-offs and Alternatives

None that I know of. USB MSC now releases only the locks it holds, and keeps them across a bus reset.

Generative AI

I used generative AI tools when creating this PR: Claude Code helped with the code and the testing. I have reviewed the code and tested it by hand on the hardware, and I'm responsible for the code and this description.

🤖 Generated with Claude Code

msc_reset_test.py
usb_reenumerate.c

vegardw and others added 2 commits November 4, 2026 13:45
A bus reset doesn't call tud_umount_cb(), so the blockdev lock that
tud_msc_is_writable_cb() took was never released. The next
tud_mount_cb() cleared locked[] anyway, blockdev_lock() then failed on
the stale flag, and the host saw a write-protected CIRCUITPY until a hard
reset. Release a lock that USB MSC still holds when USB is configured
again.

Fixes adafruit#10149.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

@dhalbert dhalbert left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Thanks!

usb_msc_umount() has a related problem, which this PR doesn't change: it releases every LUN's lock, including one held by Python or a workflow. On a board with VBUS sensing, an unplug while Python has CIRCUITPY remounted writable would clear Python's lock. Checking locked[i] there too would fix that. I can't test it on this board, which has no VBUS sensing, so I'd suggest it as a follow-up.

I would like to add this as part of this PR. If your LLM can suggest a board and a test procedure, I would test a new commit that fixes this.

@vegardw

vegardw commented Nov 4, 2026

Copy link
Copy Markdown
Author

Thanks!

usb_msc_umount() has a related problem, which this PR doesn't change: it releases every LUN's lock, including one held by Python or a workflow. On a board with VBUS sensing, an unplug while Python has CIRCUITPY remounted writable would clear Python's lock. Checking locked[i] there too would fix that. I can't test it on this board, which has no VBUS sensing, so I'd suggest it as a follow-up.

I would like to add this as part of this PR. If your LLM can suggest a board and a test procedure, I would test a new commit that fixes this.

I've gotten a suggested fix and test procedure from Claude, I'll read through it and test it myself later today, and update this PR when i have verified and tested it :)

usb_msc_umount() unlocked every LUN's blockdev, including a lock held by
a file that Python has open for writing. If the board was unplugged
(with VBUS sensing) or the host sent SET_CONFIGURATION(0) while such a
file was open, the host got write access when it came back, while Python
was still writing. Release only the locks that USB MSC holds, with the
helper that the mount path now uses too.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@vegardw

vegardw commented Nov 4, 2026

Copy link
Copy Markdown
Author

I reviewed and tested the fix from Claude and commited the fix: usb_msc_umount() now releases only the locks that USB MSC holds, through the same helper that the mount path uses. With both commits the firmware is 24 bytes smaller than before.

A more precise version of the scenario: since #11530, a file that Python opens for writing holds the filesystem lock until it is closed. filesystem_lock() also sets IGNORE_WRITE_PROTECTION, so while the file is open, the lock is the only thing keeping the host read-only. If tud_umount_cb() ran then, usb_msc_umount() cleared Python's lock, and the host got write access when it came back. tud_umount_cb() runs on an unplug with VBUS sensing, or on SET_CONFIGURATION(0).

Board: any board that shows CIRCUITPY, with a Linux host. Linux can send SET_CONFIGURATION(0) through sysfs, so no unplug or VBUS sensing is needed. For a real unplug, a Feather nRF52840 Express or a Feather STM32F405 Express running on a LiPo works, since both detect VBUS loss.

Test procedure (files attached):

  1. Copy umount_test_boot.py to CIRCUITPY as boot.py, and umount_test_code.py as code.py. Then press reset, since boot.py only runs then.
    • boot.py remounts CIRCUITPY writable for Python, so the host sees it read-only.
    • code.py opens hold.txt for writing and keeps it open.
  2. lsblk -o NAME,RO,LABEL | grep CIRCUITPY should show RO 1. Don't write to the drive during the test.
  3. Find the board in sysfs, then unconfigure and reconfigure it:
    for d in /sys/bus/usb/devices/*; do [ -f $d/idVendor ] && echo "$d $(cat $d/idVendor):$(cat $d/idProduct) $(cat $d/product 2>/dev/null)"; done
    echo 0 | sudo tee /sys/bus/usb/devices/<dev>/bConfigurationValue
    sleep 1
    echo 1 | sudo tee /sys/bus/usb/devices/<dev>/bConfigurationValue
    With a battery-powered Feather, you can instead unplug USB for a couple of seconds and plug it in again.
  4. Run lsblk -o NAME,RO,LABEL | grep CIRCUITPY again. Without the new commit, RO is now 0: the host can write while hold.txt is open. With it, RO stays 1.
  5. Clean up: press Ctrl-C, run import os; os.remove("/boot.py"); os.remove("/code.py"); os.remove("/hold.txt") at the REPL, and reset.

Tested on a Retro Arcade on macOS, using IOKit's SetConfiguration(0) followed by a re-enumeration; the attached usb_reenumerate.c does this with -u.

  • First commit only: the drive came back writable while hold.txt was open.
  • Both commits: it stayed read-only.
  • The bus-reset case from the first commit still passes.

The same steps on Linux (a Retro Arcade, bConfigurationValue as above) gave:

# first commit only
└─sdb1                 1 CIRCUITPY      # before
└─sdb1                 0 CIRCUITPY      # after the unconfigure and reconfigure: the bug
# both commits
└─sdb1                 1 CIRCUITPY      # before
└─sdb1                 1 CIRCUITPY      # after: still read-only

umount_test_boot.py
umount_test_code.py
usb_reenumerate.c

@vegardw
vegardw requested a review from dhalbert November 5, 2026 15:17
@dhalbert

Copy link
Copy Markdown
Collaborator

Sorry, this fell off my radar, but I'll look again!

@dhalbert

dhalbert commented Nov 12, 2026 •

Copy link
Copy Markdown
Collaborator

Thanks for persevering on this. I ran your fix through Claude and came up with two suggestions. There was a lot of testing: it's not easy to set up the test conditions, as you found.

Patch files for the two code changes are attached. I didn't want to push commits before you had a chance to try these changes. Could you try the patches and re-test? Thanks a lot.

1. Simpler fix for the bus reset

In _usb_msc_uneject(), keep locked[] instead of releasing the lock. locked[lun] and MP_BLOCKDEV_FLAG_LOCKED get out of sync only because _usb_msc_uneject() cleared locked[] without unlocking. If locked[] is kept across the bus reset, the next tud_msc_is_writable_cb() sees that USB MSC already holds the lock and reports the drive writable.

Releasing the lock on a bus reset and taking it again later leaves a window between tud_mount_cb() and the host's next writability check. If the web workflow or BLE file transfer calls filesystem_lock() in that window, the host sees CIRCUITPY as write-protected again.

The usb_msc_umount() change to release only the locks USB MSC holds is an important fix, so I kept it.

2. The eject path has the same problem

The eject branch of tud_msc_start_stop_cb() called blockdev_unlock() whether or not USB MSC held the lock. If Python has a file open for writing, an eject released Python's lock: storage.remount() then succeeded, the drive went back to the host as writable, and the host and Python could both write to the filesystem.

Testing

I tested on a Metro M4 Express:

Test Base This PR With the patch
Bus reset (usbreset) after CIRCUITPY is mounted, then write a file from the host writes fail: Data Protect, Write protected writable writable
Unplug (bConfigurationValue 0 → 1) while USB MSC holds the lock — writable afterwards writable afterwards
boot.py remounts CIRCUITPY writable by Python; a file is open for writing; unplug host remounts read-write and can write while the file is open File write in progress; host stays read-only File write in progress; host stays read-only
Same, but eject (sg_start --readonly --eject) instead of unplug — storage.remount() succeeds while the file is open; host and Python both write File write in progress
Eject while USB MSC holds the lock — lock released lock released

On Linux, the desktop's "Eject" doesn't send START STOP UNIT. eject gives up when CircuitPython rejects PREVENT ALLOW MEDIUM REMOVAL, so I sent the eject with sg_start.

Patches

Download both attached files and run git am 0001-*.patch 0002-*.patch on your branch.

0001-supervisor-keep-USB-MSC-locks-across-a-bus-reset.patch
0002-supervisor-release-only-USB-MSC-s-own-lock-on-eject.patch

dhalbert and others added 2 commits November 12, 2026 07:19
A bus reset calls tud_mount_cb() without tud_umount_cb(). Keep locked[]
instead of releasing the lock: tud_msc_is_writable_cb() then sees that
USB MSC already holds it and doesn't lock again. Releasing it and
locking again later leaves a window in which filesystem_lock() can take
the lock, so the host sees a write-protected drive.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The eject branch of tud_msc_start_stop_cb() called blockdev_unlock()
even when USB MSC didn't hold the lock. If Python had a file open for
writing, an eject released Python's lock: storage.remount() then
succeeded, the drive went back to the host as writable, and the host
and Python could both write to the filesystem.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@vegardw

vegardw commented Nov 12, 2026

Copy link
Copy Markdown
Author

No worries about it falling off your radar, and thanks for the patches! I agree with both changes, and they're now on the branch as your commits.

I re-tested on a Retro Arcade (STM32F412, no VBUS sensing) with a Debian 13 host. The three builds differ only in usb_msc_flash.c:

Test Base Before your patches With your patches
Bus reset (usbreset) with CIRCUITPY mounted, then write from the host (msc_reset_test.py) writes fail: Data Protect, Write protected writable writable
Bus reset with CIRCUITPY not mounted, then storage.remount("/", readonly=False) at the REPL — succeeds; Linux then sees a media change with write-protect on Cannot remount path when visible via USB.
bConfigurationValue 0 → 1 while USB MSC holds the lock — — writable afterwards
boot.py remounts CIRCUITPY writable by Python; a file is open for writing; bConfigurationValue 0 → 1 host gets RO 0 RO 1 (my earlier test) File write in progress; RO 1
Same, but eject (sg_start --readonly --eject) storage.remount() succeeds storage.remount() succeeds; after a reconfigure the host gets RO 0 while the file is open File write in progress; RO 1 after a reconfigure
Eject while USB MSC holds the lock — — lock released

The second row is the window you described. Linux doesn't read write-protect again after a device reset, and it sent no writability query, so with my version nobody held the lock until the host's next write.

For the eject rows, eject_test_code.py (below; as code.py, with the earlier umount_test_boot.py as boot.py) keeps hold.txt open and answers p on the serial console with the result of storage.remount("/", readonly=False).

With all four commits, the firmware is the same size as without the PR. I've updated the PR description to match.

eject_test_code.py
# Copy to CIRCUITPY as code.py for the eject test, with umount_test_boot.py as boot.py.
#
# Keeps a file open for writing, so Python holds the filesystem lock, as
# umount_test_code.py does. Type p and Enter in the serial console to probe
# the lock: storage.remount("/", readonly=False) must fail with "File write in
# progress" while the file is open. If it succeeds, something has released
# Python's lock. The probe leaves the drive as boot.py set it.
import storage

with open("/hold.txt", "w"):
    print("hold.txt is open for writing. Type p and Enter to probe the lock.")
    while True:
        line = input()
        if line.strip() != "p":
            print("ignored:", repr(line))
            continue
        try:
            storage.remount("/", readonly=False)
            print("PROBE: remount succeeded, so Python's lock was released (the bug)")
        except RuntimeError as error:
            print("PROBE:", error)

@dhalbert dhalbert left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Thanks for retesting the new patches. We can say this is done!

@dhalbert
dhalbert merged commit 017c7ed into adafruit:main Nov 12, 2026
604 checks passed
@vegardw
vegardw deleted the usb-msc-stale-lock branch November 12, 2026 13:57
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.

With powered hub, RP2040/RP2350 MCU file system becomes read only when PC turned off/on.

2 participants