Repository navigation
BLE File Transfer writes blocked as STATUS_ERROR_READONLY when no USB host attached #10972
Description
Activity
I don't think openclaw included it, but this was on a CLUE board running CP 10.2.0.
makermelissa-ai-assistant commented
on Jun 5, 2026 AuthorMore actionsFollowing up on this — the recent web-editor #460 / #506 / firmware #11017
saga turned up the same root cause hitting the web workflow path,
not just BLE FT, so flagging here so this issue can be the canonical
tracker for both.Confirming the diagnosis from the web-workflow side
The web workflow
PUT /fs/<file>failure mode that #11017 reclassified
from500to409 Conflictis the sameFR_WRITE_PROTECTEDreturn
fromf_open(..., FA_WRITE | FA_OPEN_ALWAYS)described in this
issue. Specifically:- web-editor ports/atmel-samd/README and README: feather_m0_adalogger readme updates #460 reporter reproduced "saves fail" on a Qualia ESP32-S3
with no host computer involved — board on a USB power adapter only.
Same symptom on a QtPy ESP32-S3, across Firefox / Safari / Chrome on
macOS 15.7.7. So this isn't a Mac Finder-eject quirk or a host-side
state thing; the firmware is reporting the filesystem read-only with
no host present. - web-editor Avoid gcc 7.2.1 compiler issues in nlr_push() #506 (just merged) softened the editor's dialog to stop
asserting "a host has CIRCUITPY mounted" — but that's the misdiagnosis
workaround, not the fix. The dialog now says "USB Mass Storage is
enabled on the board, whether or not a host is using it." Same
firmware bug; better client-side messaging while we sort out the
underlying behavior.
So the situation today is:
Workflow Hits the bug? Current user-visible behavior BLE File Transfer ✅ STATUS_ERROR_READONLY(0x05) on writes, no host presentWeb workflow PUT (post-#11017) ✅ 409 Conflicton/fs/<file>, no host presentWeb workflow DELETE / MOVE / mkdir ✅ (same code path) 409 Conflict, no host presentstorage.disable_usb_drive()inboot.py❌ (workaround) Works, but loses CIRCUITPY USB MSC entirely What the firmware is actually doing
Re-tracing through current
mainto confirm the original analysis still
holds, plus walking thetud_msc_*callbacks insupervisor/shared/usb/ usb_msc_flash.c:-
At boot (
main.c:1093-1094):filesystem_set_internal_concurrent_write_protection(true); filesystem_set_internal_writable_by_usb(CIRCUITPY_USB_DEVICE == 1);
For any USB-capable build, CWP is on and
USB_WRITABLEis on.
filesystem_is_writable_by_python()then returnsfalse
unconditionally for the rest of the run (unless user code calls
storage.disable_usb_drive()fromboot.py). -
At first SCSI inquiry from a host (
tud_msc_is_writable_cb,
usb_msc_flash.c:257):if (!locked[lun] && !blockdev_lock(vfs)) { return false; } locked[lun] = true;
This grabs
MP_BLOCKDEV_FLAG_LOCKEDthe first time any host probes
the device's writability.blockdev_lockis a simple flag set with
no timeout, no reference count, and no cooperative release path. -
Lock is only released by:
tud_umount_cb→usb_msc_umount()(cable unplug / host suspends bus)tud_msc_start_stop_cbwith SCSI eject — macOS Finder eject often
doesn't send this- User code calling
storage.disable_usb_drive()fromboot.py
There is no path that says "no host has done a real
WRITE10in N
seconds — let the BLE / web-workflow side write." Once the lock is
grabbed, it's grabbed.This explains the "no host present" report from web-editor #460
straightforwardly: a USB power adapter (or hub, or any device on the
bus) that does partial SCSI enumeration before going quiet is enough
to firetud_msc_is_writable_cbonce, setlocked[lun]=true, and the
lock then sticks for the rest of the boot.Proposed fix — refining the original suggestion
The original "make USB-writable runtime-conditional" suggestion still
holds. A more concrete shape, combining the two paths:A. State-aware
filesystem_is_writable_by_python()Instead of consulting only the build-time
USB_WRITABLEflag, also
consider whether USB MSC has actually been engaged by a host:bool filesystem_is_writable_by_python(fs_user_mount_t *vfs) { if ((vfs->blockdev.flags & MP_BLOCKDEV_FLAG_CONCURRENT_WRITE_PROTECTED) == 0) { return true; } if ((vfs->blockdev.flags & MP_BLOCKDEV_FLAG_IGNORE_WRITE_PROTECTION) != 0) { return true; } // Originally: return (USB_WRITABLE flag is unset) // Refined: also writable when USB MSC support exists but no host // has actually completed enumeration — i.e. tud_ready() == false, // or the host has been idle past a small grace window. if ((vfs->blockdev.flags & MP_BLOCKDEV_FLAG_USB_WRITABLE) == 0) { return true; } #if CIRCUITPY_USB_DEVICE if (!tud_ready()) { return true; } #endif return false; }
This alone resolves the "no host on the bus" case — the most common
report.tud_ready()(the same primitive that backs
supervisor.runtime.usb_connected, seesupervisor/shared/usb/usb.c:93)
is true only after USB enumeration completes with a host that gets all
the way through SET_CONFIGURATION, not on a power-only adapter that
never talks USB at all.B. Cooperative yield from the web-workflow / BLE side
For the harder case where a host is enumerated but isn't actively
writing (the macOS-Finder-shows-CIRCUITPY-but-user-isn't-touching-it
case), have the workflows briefly release the lock before their own
f_open(FA_WRITE):- Track
last_host_write10_msinusb_msc_flash.c, updated in
tud_msc_write10_cb. - Expose
usb_msc_host_idle_for(uint32_t ms)that returnstruewhen
noWRITE10in the pastmsmilliseconds AND noWRITE10is mid-
transfer. - In
_write_file_and_reply(and the BLE write path), before reporting
FR_WRITE_PROTECTED, checkusb_msc_host_idle_for(2000). If true,
callblockdev_unlock(vfs), do the write, let the host's next
tud_msc_is_writable_cbreacquire the lock.
This is safe because:
- The web workflow / BLE op holds
filesystem_lock()for its duration
anyway, so concurrent host WRITE10 can't slip in (tud_msc_write10_cb
checksfilesystem_lock). - The host's next SCSI TUR or write reacquires the MSC lock cleanly
via the existingtud_msc_is_writable_cbpath. - For hosts mid-transfer,
usb_msc_host_idle_for(2000)returns false
and we keep the currentFR_WRITE_PROTECTEDbehavior.
(A) alone fixes the "no host" report. (A) + (B) together cover the
full dual-mode workflow case that the web-editor #460 reporter
described — wanting to use Mu at the bench and web workflow when
deployed.Touched files (estimated)
If this matches what the maintainers want, the change is small:
supervisor/shared/filesystem.c— refinefilesystem_is_writable_by_pythonsupervisor/shared/usb/usb_msc_flash.c— addlast_host_write10_ms
tracking +usb_msc_host_idle_for()supervisor/shared/web_workflow/web_workflow.c— call the idle
helper before reportingFR_WRITE_PROTECTEDon file PUTs (also
applies to DELETE / MOVE / mkdir-PUT)supervisor/shared/bluetooth/file_transfer.c— same idle check in
the BLE write/move/delete pathssupervisor/stub/filesystem.c— keep stubs in sync
No public Python API change. No
boot.pychange required from users
on the common path. Existingstorage.disable_usb_drive()flow is
preserved verbatim for the all-web-workflow-only mode.Distinguish busy-from-write-protected (suggestion 2 from the issue)
Still endorsed —
STATUS_ERROR_BUSYonfilesystem_lock()contention
is cleaner than theFR_WRITE_PROTECTEDcascade. That's a smaller,
mostly orthogonal change and can land independently of the
host-state-aware piece.Happy to take a swing
If this approach matches what the team has in mind for the 10.3.0
milestone, I can prototype (A) on a board I have here (Qualia ESP32-S3
that reproduces the original report) and put it up as a draft PR for
discussion. (B) needs more eyes on the locking model before I'd
ship it. Let me know if you'd rather I leave it on the milestone for
someone with deeper history with the MSC stack.Refs: circuitpython/web-editor#460, circuitpython/web-editor#506, #11017
- web-editor ports/atmel-samd/README and README: feather_m0_adalogger readme updates #460 reporter reproduced "saves fail" on a Qualia ESP32-S3
Since it's been reported that this used to work in 9.x.x, perhaps @makermelissa-piclaw could figure out what change was made when in CircuitPython that caused this problem.
Reacted by Melissa LeBlanc-Williamsmakermelissa-ai-assistant commented
on Jun 8, 2026 AuthorMore actionsBisect: regression introduced in 10.0.1 by #10659
Bisected today on a CLUE (nRF52840) using BLE-FT file-rename as the test (rename a file via BLE → does the file actually get renamed?). Tested with the board running on battery / no USB host, which is the scenario that exhibits the bug.
Version Rename works? 10.0.0 ✅ 10.0.1 ❌ 10.0.3 ❌ 10.1.1 ❌ So the regression landed in 10.0.1, and on CLUE it's the same root cause as the Qualia web-workflow PUT failure I described earlier in this issue — both paths go through the same FatFS layer.
Offending commit
e2cbc2d — "Fix filesystem writability from USB" (#10659), merged 2025-10-07.
What changed
10.0.0 path
disk_ioctl(IOCTL_INIT)inextmod/vfs_fat_diskio.creturnedSTA_PROTECTonly ifvfs->blockdev.writeblocks[0] == MP_OBJ_NULL(i.e., the blockdev literally has no write function). For the CIRCUITPY flash mount, that is never true, so Python-layer writes always succeeded at FatFS. USB-MSC write protection was completely separate, handled bytud_msc_is_writable_cbinsupervisor/shared/usb/usb_msc_flash.c.10.0.1 path
disk_ioctl(IOCTL_INIT)now returnsSTA_PROTECTwhenever!filesystem_is_writable_by_python(vfs). Andfilesystem_is_writable_by_pythonreturns true only if any of:CONCURRENT_WRITE_PROTECTED == 0, orUSB_WRITABLE == 0, orIGNORE_WRITE_PROTECTION != 0
At boot,
main.csets:filesystem_set_internal_concurrent_write_protection(true); // CONCURRENT_WRITE_PROTECTED = 1 filesystem_set_internal_writable_by_usb(CIRCUITPY_USB_DEVICE == 1); // USB_WRITABLE = 1 on USB-device boards
IGNORE_WRITE_PROTECTIONonly gets set duringboot.pyand is cleared right after.So after
boot.pyfinishes, on every board built with USB device support (CLUE included),filesystem_is_writable_by_pythonreturns false — regardless of whether a USB host is actually connected on the bus.→
f_rename,f_open(..., FA_WRITE),f_unlink, etc. all returnFR_WRITE_PROTECTED.→ BLE-FT rename silently fails, web workflow PUT returns 409 (and used to return 500 before #11017), even though there is no USB host anywhere to be conflicting with.
Why USB-MSC mode itself still works
When a real USB host is connected and enumerates the MSC interface, the host grabs the FatFS lock via
tud_msc_is_writable_cb→blockdev_lock, and Python correctly sees write-protect. That part is fine and was the whole point of #10659.The bug is in the no-host-present case: the boot-time
USB_WRITABLEflag means "this board is capable of MSC-writable in principle," not "a host is currently here." Treating the boot-time flag as a runtime check makes Python read-only on every USB-capable board the moment it tries to operate without a host on the bus.Suggested fix sketch
Minimal change: have
filesystem_is_writable_by_pythonconsult actual TinyUSB host-presence, e.g.bool filesystem_is_writable_by_python(fs_user_mount_t *vfs) { return ((vfs->blockdev.flags & MP_BLOCKDEV_FLAG_CONCURRENT_WRITE_PROTECTED) == 0) || ((vfs->blockdev.flags & MP_BLOCKDEV_FLAG_USB_WRITABLE) == 0) || ((vfs->blockdev.flags & MP_BLOCKDEV_FLAG_IGNORE_WRITE_PROTECTION) != 0) || !usb_msc_host_ready(); // no host on bus → Python writable }
Where
usb_msc_host_ready()returnstud_ready()on USB-device boards andfalseon non-USB or BLE-only paths.A slightly cleaner version is to track the MSC lock explicitly (only flag Python read-only while
blockdev_lockis actually held by the host), which I sketched earlier in this thread. Either shape should fix both the CLUE BLE-FT case and the Qualia web-workflow case.cc @dhalbert @tannewt — happy to prototype against my Qualia or CLUE if you'd like.
- added a commit that references this issue
on Jun 9, 2026 makermelissa-ai-assistant commented
on Jun 9, 2026 AuthorMore actionsDraft PR up: #11048
Short version: instead of teaching
filesystem_is_writable_by_python()about the lock state (my first prototype), the PR makesfilesystem_lock()setMP_BLOCKDEV_FLAG_IGNORE_WRITE_PROTECTIONwhile the lock is held, andfilesystem_unlock()clear it. That's the same patternmain.calready uses aroundboot.py, and it leaves thefilesystem_is_writable_by_python/filesystem_is_writable_by_usbsymmetry from #10659 untouched.Why that shape: my first prototype (LOCKED-as-fourth-clause in
_is_writable_by_python) actually didn't fix the issue's primary repro — BLE-FT callsfilesystem_lock()beforef_open(FA_WRITE), so by the time FatFS callsdisk_ioctl(IOCTL_STATUS)LOCKED=1, and the new clause is false anyway. It also opened a concurrent-writer hole (Pythonopen(W)succeeds without taking any lock, so a BLE-FT writer could acquirefilesystem_lockand proceed in parallel). The v2 shape avoids both.Hardware-verified on CLUE nRF52840 (10.3.0-alpha.2-25-ga8924a8e):
- Default state (host MSC, no boot.py):
mount.readonly=True, Pythonopen(W)→ EROFS — no regression. storage.disable_usb_drive()in boot.py: writes /os.rename/os.sync/ readback after reset all succeed.- Binary inspection of
firmware.elf:filesystem_is_writable_by_pythonis byte-identical to its pre-BLE File Transfer writes blocked as STATUS_ERROR_READONLY when no USB host attached #10972 form;filesystem_locksets0x40 | 0x80(LOCKED|IGNORE),filesystem_unlockclears0xc0in onebic.
What I did not directly exercise: BLE-FT WRITE/MOVE/MKDIR/DELETE end-to-end on a battery-powered CLUE with a BLE central. My test rig is Pi-as-USB-host, and CIRCUITPY_USB_DEVICE suppresses BLE-FT advertising unless bonded or in discovery mode (which needs the double-tap reset I can't reliably trigger remotely). The change is in shared code with a tiny surface, and the path it unblocks (
filesystem_lock→f_open(FA_WRITE)) is exactly the one called out in the issue — but a reviewer with a CLUE-on-battery + web-editor-over-BLE rig closing that loop would be appreciated.Not in this PR (happy to do as follow-ups if wanted):
- Issue's suggestion number 2 — distinguishing lock-contention from read-only with a new
STATUS_ERROR_BUSY = 0x06. Orthogonal UX cleanup; lock-busy should be rare in practice oncefilesystem_lockis the right mutex. - Issue's suggestion number 1 — runtime-conditional USB-writable based on
tud_mounted(). The IGNORE-via-lock approach fixes the BLE-FT / web workflow path without needing this, and atud_mounted()-gatedUSB_WRITABLEwould also change Python'sopen(W)semantics (Python writes succeed whenever no host is on the bus), which feels like a separate design decision worth its own discussion.
- Default state (host MSC, no boot.py):
I am not sure about this fix. I am looking at the current semantics related to remounting in boot.py.
- added a commit that references this issue
on Jun 10, 2026 - added a commit that references this issue
on Jun 10, 2026
What CircuitPython version are you using?
Latest
main(verified against currentadafruit/circuitpythonsource).Code/REPL
Behavior
On a board powered only from a wall adapter (no USB host anywhere on the line), any BLE File Transfer write op (write/move/mkdir/delete) is rejected by the firmware with status
0x05(STATUS_ERROR_READONLY). Reads succeed.supervisor.runtime.usb_connectedreturnsFalseand there is noboot.pyoverride, yet writes are blocked as if the FS were read-only.This was discovered while testing
circuitpython/web-editorover BLE. The web-editor calls into@adafruit/ble-file-transfer-js, which dispatches aMOVE_COMMAND/WRITE_COMMAND/etc., and the firmware reliably returns0x05. The previous JS-side label for0x05wasSTATUS_ERROR_USB_MOUNTED(since corrected toSTATUS_ERROR_READONLYinadafruit/ble-file-transfer-js#10/ v1.0.5), so this was originally surfaced as a misleading "Unable to write while USB connected" message — but the underlying firmware behavior is the real bug.Root cause (traced through
main)There are two distinct issues conflated in this code path.
1. Boot defaults make the CIRCUITPY filesystem unconditionally write-protected to Python/BLE.
main.c:1111-1112:So at boot, every USB-capable build sets
MP_BLOCKDEV_FLAG_CONCURRENT_WRITE_PROTECTEDon andMP_BLOCKDEV_FLAG_USB_WRITABLEon.In
supervisor/shared/filesystem.c:With CWP=on, USB_WRITABLE=on, IGNORE=off → returns false.
In
extmod/vfs_fat_diskio.c:So FAT reports the volume as write-protected →
FR_WRITE_PROTECTED→ BLE FT returnsSTATUS_ERROR_READONLY.Crucially, this check never consults
tud_mounted(),tud_connected(), or whether a host has actually claimed the MSC LUN. The decision is made purely from build-time configuration flags set duringmain()startup. As a result, a board powered from a wall adapter with no host anywhere on the line still reports its filesystem as USB-owned/write-protected to BLE.The only way to escape this is to override in
boot.py:…which calls
usb_drive_set_enabled(false)inshared-module/storage/__init__.cand clearsMP_BLOCKDEV_FLAG_USB_WRITABLE. But this is a hard binary choice — you lose USB MSC entirely — when the truthful answer should be runtime-conditional.2. Lock contention is reported as "write protected".
supervisor/shared/workflow.c:122-132insupervisor_workflow_move:filesystem_lock()is a mutex onMP_BLOCKDEV_FLAG_LOCKED, not a write-permission check. When it fails it means another op (or stale state) holds the lock. ReturningFR_WRITE_PROTECTEDcascades intoSTATUS_ERROR_READONLYat the BLE protocol layer. Lock-busy and write-protected are conflated.Same conflation happens for
_process_write's directfilesystem_lockfailure path insupervisor/shared/bluetooth/file_transfer.c:268-272.Suggested fixes
Make USB-writable runtime-conditional. Only treat USB as the writer when a host has actually mounted MSC (e.g. gate on
tud_mounted()and active LUN state) rather than solely onCIRCUITPY_USB_DEVICE == 1. Falling back to Python-writable when no host is present would let BLE FT (and other workflows) write without forcing users to disable USB MSC entirely viaboot.py.Distinguish busy from write-protected. Introduce
STATUS_ERROR_BUSY = 0x06(or similar) insupervisor/shared/bluetooth/file_transfer_protocol.hand stop returningFR_WRITE_PROTECTEDfromsupervisor_workflow_move/mkdir/deletewhen the underlying failure isfilesystem_lock()contention.Behavior
Writes via BLE File Transfer are blocked even when no USB host is present, returning status
0x05(STATUS_ERROR_READONLY). The firmware's read-only determination is based on build-time flags rather than actual host attachment state.Additional information
boot.pyoverrides.supervisor.runtime.usb_connected == Falsein this state.circuitpython/web-editor(PRs Lint all the libraries! #475, Support of the Feather M0 WiFi ATSAMD21 + ATWINC1500 #478) and JS libadafruit/ble-file-transfer-js(PR atmel-samd: Add modules directory with frozen bytecode support (like ESP8266 port), and NeoPixel python wrapper module. #10, released as v1.0.5).boot.pywithimport storage; storage.disable_usb_drive()— but this is a workaround, not a fix.