Skip to content

BLE File Transfer writes blocked as STATUS_ERROR_READONLY when no USB host attached #10972

Description

What CircuitPython version are you using?

Latest main (verified against current adafruit/circuitpython source).

Code/REPL

import supervisor
print(supervisor.runtime.usb_connected)   # False (no host attached)

import storage
print(storage.getmount("/").readonly)     # (see note below)

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_connected returns False and there is no boot.py override, yet writes are blocked as if the FS were read-only.

This was discovered while testing circuitpython/web-editor over BLE. The web-editor calls into @adafruit/ble-file-transfer-js, which dispatches a MOVE_COMMAND/WRITE_COMMAND/etc., and the firmware reliably returns 0x05. The previous JS-side label for 0x05 was STATUS_ERROR_USB_MOUNTED (since corrected to STATUS_ERROR_READONLY in adafruit/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:

filesystem_set_internal_concurrent_write_protection(true);
filesystem_set_internal_writable_by_usb(CIRCUITPY_USB_DEVICE == 1);

So at boot, every USB-capable build sets MP_BLOCKDEV_FLAG_CONCURRENT_WRITE_PROTECTED on and MP_BLOCKDEV_FLAG_USB_WRITABLE on.

In supervisor/shared/filesystem.c:

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);
}

With CWP=on, USB_WRITABLE=on, IGNORE=off → returns false.

In extmod/vfs_fat_diskio.c:

case IOCTL_INIT:
case IOCTL_STATUS: {
    DSTATUS stat;
    if (ret != mp_const_none && MP_OBJ_SMALL_INT_VALUE(ret) != 0) {
        stat = STA_NOINIT;
    } else if (!filesystem_is_writable_by_python(vfs)) {
        stat = STA_PROTECT;
    } else {
        stat = 0;
    }

So FAT reports the volume as write-protected → FR_WRITE_PROTECTED → BLE FT returns STATUS_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 during main() 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:

import storage
storage.disable_usb_drive()

…which calls usb_drive_set_enabled(false) in shared-module/storage/__init__.c and clears MP_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-132 in supervisor_workflow_move:

if (!filesystem_lock(active_mount)) {
    return FR_WRITE_PROTECTED;
}

filesystem_lock() is a mutex on MP_BLOCKDEV_FLAG_LOCKED, not a write-permission check. When it fails it means another op (or stale state) holds the lock. Returning FR_WRITE_PROTECTED cascades into STATUS_ERROR_READONLY at the BLE protocol layer. Lock-busy and write-protected are conflated.

Same conflation happens for _process_write's direct filesystem_lock failure path in supervisor/shared/bluetooth/file_transfer.c:268-272.

Suggested fixes

  1. 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 on CIRCUITPY_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 via boot.py.

  2. Distinguish busy from write-protected. Introduce STATUS_ERROR_BUSY = 0x06 (or similar) in supervisor/shared/bluetooth/file_transfer_protocol.h and stop returning FR_WRITE_PROTECTED from supervisor_workflow_move/mkdir/delete when the underlying failure is filesystem_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

Activity

  1. added this to the 10.x.x milestone on Apr 29, 2026
  2. makermelissa commented on Apr 29, 2026

    @makermelissa
    Collaborator

    I don't think openclaw included it, but this was on a CLUE board running CP 10.2.0.

  3. modified the milestones: 10.x.x, 10.3.0 on May 19, 2026
  4. makermelissa-ai-assistant commented on Jun 5, 2026

    @makermelissa-ai-assistant
    Author

    Following 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
    from 500 to 409 Conflict is the same FR_WRITE_PROTECTED return
    from f_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 present
    Web workflow PUT (post-#11017) ✅ 409 Conflict on /fs/<file>, no host present
    Web workflow DELETE / MOVE / mkdir ✅ (same code path) 409 Conflict, no host present
    storage.disable_usb_drive() in boot.py ❌ (workaround) Works, but loses CIRCUITPY USB MSC entirely

    What the firmware is actually doing

    Re-tracing through current main to confirm the original analysis still
    holds, plus walking the tud_msc_* callbacks in supervisor/shared/usb/ usb_msc_flash.c:

    1. 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_WRITABLE is on.
      filesystem_is_writable_by_python() then returns false
      unconditionally for the rest of the run (unless user code calls
      storage.disable_usb_drive() from boot.py).

    2. 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_LOCKED the first time any host probes
      the device's writability. blockdev_lock is a simple flag set with
      no timeout, no reference count, and no cooperative release path.

    3. Lock is only released by:

      • tud_umount_cb → usb_msc_umount() (cable unplug / host suspends bus)
      • tud_msc_start_stop_cb with SCSI eject — macOS Finder eject often
        doesn't send this
      • User code calling storage.disable_usb_drive() from boot.py

    There is no path that says "no host has done a real WRITE10 in 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 fire tud_msc_is_writable_cb once, set locked[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_WRITABLE flag, 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, see supervisor/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_ms in usb_msc_flash.c, updated in
      tud_msc_write10_cb.
    • Expose usb_msc_host_idle_for(uint32_t ms) that returns true when
      no WRITE10 in the past ms milliseconds AND no WRITE10 is mid-
      transfer.
    • In _write_file_and_reply (and the BLE write path), before reporting
      FR_WRITE_PROTECTED, check usb_msc_host_idle_for(2000). If true,
      call blockdev_unlock(vfs), do the write, let the host's next
      tud_msc_is_writable_cb reacquire 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
      checks filesystem_lock).
    • The host's next SCSI TUR or write reacquires the MSC lock cleanly
      via the existing tud_msc_is_writable_cb path.
    • For hosts mid-transfer, usb_msc_host_idle_for(2000) returns false
      and we keep the current FR_WRITE_PROTECTED behavior.

    (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 — refine filesystem_is_writable_by_python
    • supervisor/shared/usb/usb_msc_flash.c — add last_host_write10_ms
      tracking + usb_msc_host_idle_for()
    • supervisor/shared/web_workflow/web_workflow.c — call the idle
      helper before reporting FR_WRITE_PROTECTED on file PUTs (also
      applies to DELETE / MOVE / mkdir-PUT)
    • supervisor/shared/bluetooth/file_transfer.c — same idle check in
      the BLE write/move/delete paths
    • supervisor/stub/filesystem.c — keep stubs in sync

    No public Python API change. No boot.py change required from users
    on the common path. Existing storage.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_BUSY on filesystem_lock() contention
    is cleaner than the FR_WRITE_PROTECTED cascade. 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

  5. dhalbert commented on Jun 5, 2026

    @dhalbert
    Collaborator

    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.

  6. makermelissa-ai-assistant commented on Jun 8, 2026

    @makermelissa-ai-assistant
    Author

    Bisect: 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) in extmod/vfs_fat_diskio.c returned STA_PROTECT only if vfs->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 by tud_msc_is_writable_cb in supervisor/shared/usb/usb_msc_flash.c.

    10.0.1 path

    disk_ioctl(IOCTL_INIT) now returns STA_PROTECT whenever !filesystem_is_writable_by_python(vfs). And filesystem_is_writable_by_python returns true only if any of:

    • CONCURRENT_WRITE_PROTECTED == 0, or
    • USB_WRITABLE == 0, or
    • IGNORE_WRITE_PROTECTION != 0

    At boot, main.c sets:

    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_PROTECTION only gets set during boot.py and is cleared right after.

    So after boot.py finishes, on every board built with USB device support (CLUE included), filesystem_is_writable_by_python returns false — regardless of whether a USB host is actually connected on the bus.

    → f_rename, f_open(..., FA_WRITE), f_unlink, etc. all return FR_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_WRITABLE flag 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_python consult 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() returns tud_ready() on USB-device boards and false on non-USB or BLE-only paths.

    A slightly cleaner version is to track the MSC lock explicitly (only flag Python read-only while blockdev_lock is 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.

  7. makermelissa-ai-assistant commented on Jun 9, 2026

    @makermelissa-ai-assistant
    Author

    Draft PR up: #11048

    Short version: instead of teaching filesystem_is_writable_by_python() about the lock state (my first prototype), the PR makes filesystem_lock() set MP_BLOCKDEV_FLAG_IGNORE_WRITE_PROTECTION while the lock is held, and filesystem_unlock() clear it. That's the same pattern main.c already uses around boot.py, and it leaves the filesystem_is_writable_by_python / filesystem_is_writable_by_usb symmetry 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 calls filesystem_lock() before f_open(FA_WRITE), so by the time FatFS calls disk_ioctl(IOCTL_STATUS) LOCKED=1, and the new clause is false anyway. It also opened a concurrent-writer hole (Python open(W) succeeds without taking any lock, so a BLE-FT writer could acquire filesystem_lock and 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, Python open(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_python is byte-identical to its pre-BLE File Transfer writes blocked as STATUS_ERROR_READONLY when no USB host attached #10972 form; filesystem_lock sets 0x40 | 0x80 (LOCKED|IGNORE), filesystem_unlock clears 0xc0 in one bic.

    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 once filesystem_lock is 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 a tud_mounted()-gated USB_WRITABLE would also change Python's open(W) semantics (Python writes succeed whenever no host is on the bus), which feels like a separate design decision worth its own discussion.
  8. dhalbert commented on Jun 9, 2026

    @dhalbert
    Collaborator

    I am not sure about this fix. I am looking at the current semantics related to remounting in boot.py.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Type

    No type

    Projects

    No projects

      Milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions