Skip to content
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
113 changes: 113 additions & 0 deletions docs/LINUX_USB_MOUNT.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,113 @@
# Linux USB Mount Notes (CIRCUITPY)

If you use the CircuitPython Web Editor on Linux with the **USB workflow**
and you see

```
OSError: [Errno 5] Input/output error
```

in the serial terminal after pressing <kbd>Ctrl</kbd>+<kbd>D</kbd> following
a save, this page is for you. The cause is in the host operating system,
not the editor and not your CircuitPython device.

## What is happening

When the browser writes to `code.py` (or any other file on the CIRCUITPY
drive), it goes through the operating system's filesystem layer. On Linux,
the default behavior of the `vfat` filesystem on a USB Mass Storage Class
(MSC) device is to **buffer those writes in the kernel's page cache** and
only push them to the device some time later (the kernel default is up to
~30 seconds).

When you then press <kbd>Ctrl</kbd>+<kbd>D</kbd>, CircuitPython on the
device tries to import `code.py` immediately. If the host hasn't yet
flushed its writes, the device sees an inconsistent filesystem and
returns `OSError: [Errno 5] Input/output error`.

This only affects **Linux** with the **USB MSC workflow**. macOS and
Windows do not have this issue. Network and Bluetooth workflows are also
unaffected.

## Quick fix (current session)

Remount the CIRCUITPY drive with the `sync` mount option so writes are
sent to the device synchronously:

```sh
udisksctl unmount -b /dev/sdX1
udisksctl mount -b /dev/sdX1 -o sync
```

Replace `/dev/sdX1` with the actual block device. Find it with:

```sh
lsblk
# or
mount | grep CIRCUITPY
```

After this, return to the editor, **reconnect** (the previous filesystem
handle is invalidated by the remount), and resume editing.

## Permanent fix: udev rule

To make every CircuitPython device automount with `sync` going forward,
add a `udev` rule.

1. Find your board's USB Vendor ID (`idVendor`) and Product ID
(`idProduct`) using `lsusb`. CircuitPython boards often share VID
`239a` (Adafruit) but PIDs vary by board.

```sh
lsusb
```

2. Create `/etc/udev/rules.d/99-circuitpython-sync.rules` with:

```
# CircuitPython CIRCUITPY drive: mount synchronously to avoid
# OSError: [Errno 5] Input/output error from web-editor saves.
ENV{ID_FS_LABEL}=="CIRCUITPY", ENV{UDISKS_MOUNT_OPTIONS}+="sync"
```

The label-based match catches any CircuitPython board, regardless of
VID/PID. If you'd rather scope it tighter, you can add additional
`ATTRS{idVendor}=="..."` clauses.

3. Reload udev rules:

```sh
sudo udevadm control --reload-rules
sudo udevadm trigger
```

4. Replug your CircuitPython board (or simply unmount/remount the
CIRCUITPY drive). It should now appear with `sync` in its mount
options. Verify with:

```sh
mount | grep CIRCUITPY
```

You should see something like:

```
/dev/sda1 on /media/<user>/CIRCUITPY type vfat (rw,...,sync,...)
```

## Trade-offs

The `sync` mount option means every write to CIRCUITPY blocks until the
device has accepted the data. For interactive editing of small files
this is generally not noticeable. For copying large files (firmware
updates, large libraries) it will be slower than the default async
behavior, but the data is more reliably on the device when the copy
returns.

## See also

- [Issue #229](https://github.com/circuitpython/web-editor/issues/229)
— original report and discussion
- `man 8 mount` — see the `sync` option under FILESYSTEM-INDEPENDENT
MOUNT OPTIONS
11 changes: 11 additions & 0 deletions index.html
Original file line number Diff line number Diff line change
Expand Up @@ -338,6 +338,17 @@ <h1>Select Serial Device</h1>
<div class="step-content">
<h1>Select USB Host Folder</h1>
<p>Select the root folder of your device. This is typically the CIRCUITPY Drive on your computer unless you renamed it. If your device does not appear as a drive on your computer, it will need to have the USB Host functionality enabled.</p>
<details id="linux-mount-notice" class="linux-mount-notice" hidden>
<summary><strong>Linux:</strong> seeing <code>OSError: [Errno 5]</code> after <kbd>Ctrl</kbd>+<kbd>D</kbd>? Click for fix.</summary>
<p>Your CIRCUITPY drive is mounted with asynchronous writes. Remount it with <code>sync</code>:</p>
<pre><code>udisksctl unmount -b /dev/sdX1
udisksctl mount -b /dev/sdX1 -o sync</code></pre>
<p>Replace <code>sdX1</code> with the correct device. Find it with:</p>
<pre><code>lsblk</code></pre>
<p>or:</p>
<pre><code>mount | grep CIRCUITPY</code></pre>
<p>For a permanent fix, see <a href="https://github.com/circuitpython/web-editor/blob/main/docs/LINUX_USB_MOUNT.md" target="_blank" rel="noopener">Linux USB mount notes</a>.</p>
</details>
<p>
<button class="purple-button hidden" id="useHostFolder"><span id="workingFolder"></span></button>
<button class="purple-button first-item" id="selectHostFolder">Select New Folder</button>
Expand Down
21 changes: 21 additions & 0 deletions js/common/utilities.js
Original file line number Diff line number Diff line change
Expand Up @@ -75,6 +75,26 @@ function isChromeOs() {
return false;
}

// Test to see if browser is running on Linux (and is not Chrome OS or
// Android, which can also report "Linux" in the legacy userAgent string).
function isLinux() {
// Newer test on Chromium
if (navigator.userAgentData?.platform === "Linux") {
return true;
}
// Avoid false positives for Chrome OS and Android.
if (isChromeOs()) {
return false;
}
if (navigator.userAgent.includes("Android")) {
return false;
}
if (navigator.userAgent.includes("Linux")) {
return true;
}
return false;
}

// Parse out the url parameters from the current url
function getUrlParams() {
// This should look for and validate very specific values
Expand Down Expand Up @@ -167,6 +187,7 @@ export {
isLocal,
isMicrosoftWindows,
isChromeOs,
isLinux,
getUrlParams,
getUrlParam,
timeout,
Expand Down
12 changes: 11 additions & 1 deletion js/workflows/usb.js
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ import {GenericModal, DeviceInfoModal} from '../common/dialogs.js';
import {FileOps} from '@adafruit/circuitpython-repl-js'; // Use this to determine which FileTransferClient to load
import {FileTransferClient as ReplFileTransferClient} from '../common/repl-file-transfer.js';
import {FileTransferClient as FSAPIFileTransferClient} from '../common/fsapi-file-transfer.js';
import { isChromeOs, isMicrosoftWindows } from '../common/utilities.js';
import { isChromeOs, isLinux, isMicrosoftWindows } from '../common/utilities.js';

let btnRequestSerialDevice, btnSelectHostFolder, btnUseHostFolder, lblWorkingfolder;

Expand Down Expand Up @@ -211,6 +211,16 @@ class USBWorkflow extends Workflow {
btnUseHostFolder = modal.querySelector('#useHostFolder');
lblWorkingfolder = modal.querySelector('#workingFolder');

// Show the Linux-only mount-option notice when relevant (#229).
// CIRCUITPY mounted without `sync` on Linux can produce
// "OSError: [Errno 5] Input/output error" on Ctrl-D after a save
// because Chromium's File System Access writes are deferred by the
// kernel writeback for up to ~30s.
const linuxNotice = modal.querySelector('#linux-mount-notice');
if (linuxNotice) {
linuxNotice.hidden = !isLinux();
}

// Map the button states to the buttons
this.connectButtons = {
request: btnRequestSerialDevice,
Expand Down
67 changes: 67 additions & 0 deletions sass/layout/_layout.scss
Original file line number Diff line number Diff line change
Expand Up @@ -542,3 +542,70 @@
}

}

// Inline notice shown in the USB connect dialog for Linux users (#229).
.linux-mount-notice {
margin-top: 1rem;
padding: 0.5rem 1rem;
border-left: 4px solid #f0ad4e;
background-color: #fff8e1;
border-radius: 3px;
font-size: 0.95em;

summary {
cursor: pointer;
padding: 0.25rem 0;
list-style: none;
position: relative;
padding-left: 1.25rem;

&::-webkit-details-marker {
display: none;
}

&::before {
content: "\25B6"; // right-pointing triangle
position: absolute;
left: 0;
font-size: 0.8em;
transition: transform 150ms ease-in-out;
display: inline-block;
}
}

&[open] summary {
margin-bottom: 0.5rem;

&::before {
transform: rotate(90deg);
}
}

p {
margin: 0.25rem 0;
}

pre {
margin: 0.5rem 0;
padding: 0.5rem;
background-color: #2d2d2d;
color: #f1f1f1;
border-radius: 3px;
overflow-x: auto;
font-size: 0.9em;
}

code {
font-family: monospace;
}

kbd {
display: inline-block;
padding: 1px 4px;
font-family: monospace;
font-size: 0.9em;
background-color: #eee;
border: 1px solid #ccc;
border-radius: 3px;
}
}