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
37 changes: 37 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,43 @@ A live copy of the tool is hosted here: https://code.circuitpython.org
1. Run `npm run build` or `npx vite build` to generate a static website.
2. Copy and deploy all files and folders in `./dist/` to your webserver.

## Troubleshooting

### `OSError: [Errno 5] Input/output error` on Linux after Save+Run

On Linux, the CIRCUITPY drive is mounted asynchronously by default. After the editor writes `code.py` over USB Mass Storage (the FS Access API), the host kernel can hold the file's data sectors in its page cache for up to ~30 seconds before flushing them to the device. If CircuitPython tries to read `code.py` before that flush completes, it raises `OSError: [Errno 5] Input/output error`.

The editor mitigates this by waiting (with a Blinka spinner) for the device to confirm it can read the full file before sending a soft-reboot. This covers the **Run** and **Reboot** buttons and Ctrl-D pressed in the terminal panel.

The built-in wait gives up after 60 seconds and falls through to the existing save-retry loop. That window comfortably covers the default Linux flush behavior (`vm.dirty_expire_centisecs` = 3000), but it can be exceeded on hosts running laptop-mode tools or other power-saving configs (which push the expire window to 60s+), on slow or contended USB buses, or when writing larger files. If you regularly see the Blinka loader time out, apply one of the workarounds below to short-circuit the wait.

If you want to eliminate the wait entirely (and the underlying race), pick one of the following workarounds on your Linux host:

**Option A — Mount CIRCUITPY synchronously (recommended; eliminates the wait).** With this rule the host commits writes inside `close()`, so the editor's flush-detector poll matches on its first attempt and the Run/Reboot/Ctrl-D actions feel instant. Add a udev rule:

```
# /etc/udev/rules.d/99-circuitpy.rules
ACTION=="add", KERNEL=="sd[a-z]*", ATTRS{idVendor}=="239a", ENV{ID_FS_LABEL}=="CIRCUITPY", ENV{UDISKS_MOUNT_OPTIONS}="sync,flush"
```

Then reload with `sudo udevadm control --reload-rules && sudo udevadm trigger`. The CIRCUITPY drive will be mounted with `sync,flush` on next reconnect, so writes commit immediately at a small write-speed cost.

**Option B — Reduce the kernel's dirty-page expire window** (host-wide; affects all writes, not just CIRCUITPY):

```
sudo sysctl -w vm.dirty_expire_centisecs=100
```

**Option C — Run `sync` in a terminal after saving** (no setup required). Opening a terminal on your Linux host and typing:

```
sync
```

forces the kernel to flush all pending writes immediately. If you run it right after the editor finishes saving, the editor's flush-detector poll matches on its next attempt and the wait completes quickly. Useful as a one-off speed-up when you don't want to install the udev rule.

Note: ChromeOS users cannot apply Options A or B and should rely on the editor's built-in wait.

## License

This project is made available under the MIT License. For more details, see the LICENSE file in the repository.
50 changes: 50 additions & 0 deletions js/common/fsapi-file-transfer.js
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,25 @@ class FileTransferClient {
this.connectionStatus = connectionStatusCB;
this._dirHandle = null;
this._uid = uid;
// Tracks the most recent file we wrote via FSAPI and its byte length.
// On Linux, writes can sit in the kernel page cache for up to ~30s
// before the FAT-mounted CIRCUITPY drive is flushed. Consumers (e.g.,
// softRestart) can read this and poll the device side until the host
// has actually committed the bytes. See issue #229.
this._lastWrite = null; // { path: string, byteLength: number, at: number }
}

// Returns the most recent FSAPI write, or null if there hasn't been one
// since the client was created. Callers should treat the result as read-only.
getLastWrite() {
return this._lastWrite;
}

// Mark the last-write tracker as resolved (host flush confirmed, or the
// caller has decided to stop waiting). Prevents repeated polls for the
// same write across multiple consecutive softRestart()s.
clearLastWrite() {
this._lastWrite = null;
}

async readOnly() {
Expand Down Expand Up @@ -171,6 +190,37 @@ class FileTransferClient {
}
await writable.write(contents);
await writable.close();

// Record the expected byte length for the host-flush wait (issue #229).
// `contents` has already been encoded to a Uint8Array above when raw
// was false, and the raw-mode path operates on a typed array, so
// byteLength is the on-disk byte size in both cases.
try {
const expectedSize = (offset || 0) + (contents.byteLength || contents.length || 0);
// Compute a quick FNV-1a-style xor-sum across the bytes so a
// device-side reader can confirm the data sectors (not just the
// FAT directory entry) are present and correct.
let checksum = 0;
try {
const bytes = (contents instanceof Uint8Array)
? contents
: new TextEncoder().encode(String(contents));
for (let i = 0; i < bytes.length; i++) {
checksum = (checksum ^ bytes[i]) & 0xff;
}
} catch (_e) {
checksum = -1;
}
this._lastWrite = {
path: path,
byteLength: expectedSize,
checksum: checksum,
at: Date.now(),
};
} catch (_e) {
// Tracker is best-effort; never let it break a successful write.
this._lastWrite = null;
}
}

_splitPath(path) {
Expand Down
28 changes: 28 additions & 0 deletions js/common/utilities.js
Original file line number Diff line number Diff line change
Expand Up @@ -75,6 +75,33 @@ function isChromeOs() {
return false;
}

// Test to see if browser is running on Linux (and NOT Chrome OS or Android,
// both of which include "Linux" in their UA strings). Used to gate the
// host-flush wait introduced for issue #229: on Linux the kernel page cache
// can hold writes to a vfat-mounted CIRCUITPY drive for up to ~30s before
// the device sees them, which races a Ctrl-D soft restart.
function isLinux() {
// Newer test on Chromium browsers.
if (navigator.userAgentData?.platform) {
const platform = navigator.userAgentData.platform;
if (platform === "Linux") {
return true;
}
// userAgentData.platform is authoritative when present: if it says
// ChromeOS or Android, we are not on "plain Linux" even though the
// legacy UA below would match.
return false;
}
// Legacy UA fallback: exclude ChromeOS and Android explicitly.
if (navigator.userAgent.includes("CrOS")) {
return false;
}
if (navigator.userAgent.includes("Android")) {
return false;
}
return navigator.userAgent.includes("Linux");
}

// 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 +194,7 @@ export {
isLocal,
isMicrosoftWindows,
isChromeOs,
isLinux,
getUrlParams,
getUrlParam,
timeout,
Expand Down
5 changes: 4 additions & 1 deletion js/script.js
Original file line number Diff line number Diff line change
Expand Up @@ -653,7 +653,10 @@ async function setupXterm() {
state.terminal.open(document.getElementById('terminal'));
state.terminal.onData(async (data) => {
if (await checkConnected()) {
workflow.serialTransmit(data);
// Route through the flush-guard wrapper so a user-typed Ctrl-D
// right after a save waits for the host kernel to flush before
// the device reads code.py. See issue #229.
await workflow.serialTransmitWithFlushGuard(data);
}
});
}
Expand Down
135 changes: 135 additions & 0 deletions js/workflows/workflow.js
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,21 @@ import {ButtonValueDialog, UnsavedDialog} from '../common/dialogs.js';
import {FileDialog, FILE_DIALOG_OPEN, FILE_DIALOG_SAVE} from '../common/file_dialog.js';
import {CONNTYPE, CONNSTATE} from '../constants.js';
import {plotValues} from '../common/plotter.js'
import {isLinux, sleep} from '../common/utilities.js';

// How long we are willing to wait for the host kernel to flush a pending
// FSAPI write down to the CIRCUITPY drive before we send Ctrl-D. The kernel's
// default dirty_expire_centisecs on most Linux distros is 3000 (=30s), but
// laptop-mode and similar power-saving configs can push it to 60s or beyond,
// and slow USB buses or large files extend the actual flush time further.
// 60s covers the common laptop-mode case while still falling through to the
// existing save-retry loop if the flush genuinely never completes. Issue #229.
const HOST_FLUSH_TIMEOUT_MS = 60000;
// Poll interval while waiting. Keep low so we proceed quickly once the kernel
// does flush. Each poll opens the file on the device and checksums its
// contents to confirm the data sectors (not just the FAT directory entry)
// have been flushed by the host kernel.
const HOST_FLUSH_POLL_MS = 500;

/*
* This class will encapsulate all of the common workflow-related functions
Expand Down Expand Up @@ -98,6 +113,120 @@ class Workflow {
return await this.available();
}

// On Linux + FSAPI workflow, the host kernel can hold a just-written file
// in its page cache for up to ~30s before flushing to the vfat-mounted
// CIRCUITPY drive. Sending Ctrl-D before that happens makes CircuitPython
// try to import a half-written file and bail with OSError [Errno 5].
//
// This helper polls the device's view of the filesystem via REPL until it
// sees the file at the expected size (= host has flushed) or we hit the
// timeout (in which case we fall through and let the caller proceed,
// because waiting forever is worse than a possibly-failing reboot that
// the existing retry path can recover from). See issue #229.
//
// Public wrapper shows the busy loader for the duration of the wait so
// the user knows the UI is not frozen during the (potentially ~30s) wait.
async _waitForHostFlush() {
// Quick non-async checks first so we never flash the loader when
// there is nothing to wait for. Mirrors early-exits in the impl.
if (!isLinux() || !this.fileHelper) {
return;
}
const fileClient = this.fileHelper.getFileClient?.();
if (!fileClient || typeof fileClient.getLastWrite !== "function") {
return;
}
if (!fileClient.getLastWrite()) {
return;
}
await this.showBusy(this._waitForHostFlushImpl(fileClient));
}

// Intercepted serial-transmit used by the terminal panel. When the user
// types Ctrl-D directly in the terminal we route it through the same
// host-flush wait used by the Run / Reboot buttons. Without this, a
// user-initiated Ctrl-D right after a save would race the kernel page
// cache flush and trigger OSError [Errno 5]. Issue #229.
async serialTransmitWithFlushGuard(data) {
// \x04 = Ctrl-D, which CircuitPython interprets as a soft reboot
// when received at the normal prompt. Only intercept if our
// host-flush guard has something pending; otherwise pass straight
// through to keep terminal latency low.
if (typeof data === "string" && data.includes("\x04")
&& isLinux() && this.fileHelper) {
const fileClient = this.fileHelper.getFileClient?.();
if (fileClient && typeof fileClient.getLastWrite === "function"
&& fileClient.getLastWrite()) {
await this._waitForHostFlush();
}
}
return await this.serialTransmit(data);
}

async _waitForHostFlushImpl(fileClient) {
const pending = fileClient.getLastWrite();
if (!pending) {
return;
}
const {path, byteLength, checksum} = pending;
// Escape any single quotes / backslashes in the path before injecting
// into the python snippet below.
const safePath = String(path).replace(/\\/g, "\\\\").replace(/'/g, "\\'");
// Probe both the FAT directory entry (os.stat) AND the file's data
// sectors (read all bytes and xor-checksum them). Linux can update
// the directory metadata block before flushing the data block, so
// os.stat alone is not a sufficient flush detector. We compare the
// xor sum to the host-computed value to confirm correct content is
// present on the device.
const code = `
try:
import os
_s = os.stat('${safePath}')[6]
with open('${safePath}', 'rb') as _f:
_b = _f.read()
_c = 0
for _x in _b:
_c = (_c ^ _x) & 0xff
print(_s, _c, len(_b))
except OSError:
print(-1, -1, -1)
`;
const start = Date.now();
while (Date.now() - start < HOST_FLUSH_TIMEOUT_MS) {
let result;
try {
result = await this.repl.runCode(code);
} catch (e) {
console.warn("Host-flush poll failed, proceeding without wait:", e);
return;
}
const match = String(result || "").match(/(-?\d+)\s+(-?\d+)\s+(-?\d+)/);
if (match) {
const size = parseInt(match[1], 10);
const devChecksum = parseInt(match[2], 10);
const readLen = parseInt(match[3], 10);
// Require all three to confirm the data sectors (not just
// the FAT directory entry) are flushed: correct size, full
// readable length, and matching checksum. Linux can update
// the directory block before the data block, so os.stat
// alone is not a sufficient flush detector.
if (size >= byteLength && readLen >= byteLength
&& (checksum < 0 || devChecksum === checksum)) {
fileClient.clearLastWrite?.();
return;
}
}
await sleep(HOST_FLUSH_POLL_MS);
}
console.warn(
`Host-flush wait timed out after ${HOST_FLUSH_TIMEOUT_MS}ms for ` +
`${path} (expected ${byteLength} bytes). Proceeding anyway; if the ` +
`reboot fails the editor's save-retry logic will recover.`
);
// Leave the tracker set so the next softRestart will retry the wait
// in case the kernel eventually flushes between now and then.
}

async restartDevice() {
if (await this.safeMode()) {
let result = await this._okCancelDialog.open("Device is currently in safe mode. Reboot device?");
Expand All @@ -106,6 +235,7 @@ class Workflow {
await this.rebootDevice();
}
}
await this._waitForHostFlush();
await this.repl.softRestart();
}

Expand Down Expand Up @@ -271,6 +401,11 @@ except ImportError:

await this._showSerial();

// Wait for any pending Linux page-cache flush before either path:
// Ctrl-D would race code.py's read on a soft restart, and `import X`
// would race X.py's bytecode read on first import. See issue #229.
await this._waitForHostFlush();

if (path == "/code.py") {
await this.repl.softRestart();
} else {
Expand Down
9 changes: 9 additions & 0 deletions sass/layout/_layout.scss
Original file line number Diff line number Diff line change
Expand Up @@ -236,6 +236,15 @@
text-decoration: underline;
}

// Suppress the focus outline rectangle (matches other anchors in
// the editor; the underline-on-hover treatment is the active state).
&:focus,
&:focus-visible,
&:active {
outline: none;
box-shadow: none;
}

i {
margin-right: 0.35rem;
}
Expand Down