diff --git a/README.md b/README.md index 3a5312a..b11d0c0 100644 --- a/README.md +++ b/README.md @@ -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. diff --git a/js/common/fsapi-file-transfer.js b/js/common/fsapi-file-transfer.js index efc242b..d145a11 100644 --- a/js/common/fsapi-file-transfer.js +++ b/js/common/fsapi-file-transfer.js @@ -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() { @@ -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) { diff --git a/js/common/utilities.js b/js/common/utilities.js index 4ad4824..afa9780 100644 --- a/js/common/utilities.js +++ b/js/common/utilities.js @@ -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 @@ -167,6 +194,7 @@ export { isLocal, isMicrosoftWindows, isChromeOs, + isLinux, getUrlParams, getUrlParam, timeout, diff --git a/js/script.js b/js/script.js index 016dde3..6b35335 100644 --- a/js/script.js +++ b/js/script.js @@ -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); } }); } diff --git a/js/workflows/workflow.js b/js/workflows/workflow.js index e04be99..b472640 100644 --- a/js/workflows/workflow.js +++ b/js/workflows/workflow.js @@ -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 @@ -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?"); @@ -106,6 +235,7 @@ class Workflow { await this.rebootDevice(); } } + await this._waitForHostFlush(); await this.repl.softRestart(); } @@ -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 { diff --git a/sass/layout/_layout.scss b/sass/layout/_layout.scss index 6ab229f..fa9dc22 100644 --- a/sass/layout/_layout.scss +++ b/sass/layout/_layout.scss @@ -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; }