From d64415b14af5d5e92bcee04a696f7e548144b2c9 Mon Sep 17 00:00:00 2001 From: Piclaw Date: Tue, 12 May 2026 14:05:14 -0700 Subject: [PATCH 1/9] Wait for host page-cache flush before Ctrl-D on Linux (#229) On Linux, writes through the File System Access API land in the kernel page cache and are flushed to a vfat-mounted CIRCUITPY drive on the kernel's writeback timer (~30s by default). Sending Ctrl-D before that flush completes makes CircuitPython try to import a half-written code.py and fail with OSError: [Errno 5] Input/output error. The File System Access API does not expose fsync, so we cannot force the flush from JS. Instead, gate every soft restart on the device's own view of the filesystem: poll os.stat(path)[6] via REPL until the size matches the bytes we just wrote, then send Ctrl-D. - FSAPI client now records {path, byteLength, at} on each writable.close() and exposes getLastWrite() / clearLastWrite(). - Workflow gains _waitForHostFlush() which is awaited before every softRestart() (run-current and reboot-button paths). It is a no-op on non-Linux, non-FSAPI, or when no write is pending. - The wait is wrapped in showBusy() so the loader is visible during the (potentially up-to-35s) wait. - Caps at 35s and falls through if the kernel never flushes; the existing 3-retry save logic recovers from a failed reboot. - isLinux() added to utilities.js (and exported), with the same ChromeOS/Android exclusions used elsewhere. Refs #229 --- js/common/fsapi-file-transfer.js | 35 ++++++++++++ js/common/utilities.js | 28 ++++++++++ js/workflows/workflow.js | 95 ++++++++++++++++++++++++++++++++ 3 files changed, 158 insertions(+) diff --git a/js/common/fsapi-file-transfer.js b/js/common/fsapi-file-transfer.js index efc242b9..7449b852 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,22 @@ 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); + this._lastWrite = { + path: path, + byteLength: expectedSize, + 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 4ad48244..afa97805 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/workflows/workflow.js b/js/workflows/workflow.js index e04be993..ee0f1d75 100644 --- a/js/workflows/workflow.js +++ b/js/workflows/workflow.js @@ -6,6 +6,16 @@ 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), so 35s +// gives a small safety margin. Issue #229. +const HOST_FLUSH_TIMEOUT_MS = 35000; +// Poll interval while waiting. Keep low so we proceed quickly once the kernel +// does flush. Each poll is a tiny `os.stat()` via REPL. +const HOST_FLUSH_POLL_MS = 500; /* * This class will encapsulate all of the common workflow-related functions @@ -98,6 +108,85 @@ 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 the early-exits in _waitForHostFlushImpl. + if (!isLinux() || !this.fileHelper) { + return; + } + const fileClient = this.fileHelper.getFileClient?.(); + if (!fileClient || typeof fileClient.getLastWrite !== "function") { + return; + } + if (!fileClient.getLastWrite()) { + return; + } + // There IS a pending write to wait on. Show the busy overlay so the + // user knows the editor is intentionally pausing. + await this.showBusy(this._waitForHostFlushImpl(fileClient)); + } + + async _waitForHostFlushImpl(fileClient) { + const pending = fileClient.getLastWrite(); + if (!pending) { + return; + } + const {path, byteLength} = pending; + // Escape any single quotes / backslashes in the path before injecting + // into the python snippet below. + const safePath = String(path).replace(/\\/g, "\\\\").replace(/'/g, "\\'"); + const code = ` +try: + import os + print(os.stat('${safePath}')[6]) +except OSError: + print(-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; + } + // runCode returns the REPL's stdout. We printed a single integer. + const match = String(result || "").match(/-?\d+/); + if (match) { + const size = parseInt(match[0], 10); + if (size >= byteLength) { + // Device sees the full write; safe to proceed. Clear the + // tracker so we do not re-poll on the next softRestart + // for the same write. + 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 +195,7 @@ class Workflow { await this.rebootDevice(); } } + await this._waitForHostFlush(); await this.repl.softRestart(); } @@ -271,6 +361,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 { From b3b82366bd1ab8b8b839d8228988a2c4251c09e1 Mon Sep 17 00:00:00 2001 From: Piclaw Date: Tue, 12 May 2026 15:30:33 -0700 Subject: [PATCH 2/9] Verify file content (not just os.stat) for host-flush detection (#229) On Linux vfat, the kernel can update the FAT directory entry before flushing the actual file data sectors. os.stat() alone returns the correct size before the device can actually read the file, so a poll that only checks size is not a sufficient flush detector. Instead, each poll opens the file on the device, reads all bytes, and computes a small xor checksum. We compare it to a host-computed checksum recorded at write time. Only when size, readable length, and checksum all match do we proceed to softRestart. Tested on Raspberry Pi 5 (kernel 6.12) with a Feather RP2040 running CircuitPython 10.2.0. Polling typically resolves at ~33s (just inside the kernel's 30s dirty_expire window); bumped timeout from 35s to 40s for headroom. --- js/common/fsapi-file-transfer.js | 15 +++++++++ js/workflows/workflow.js | 52 +++++++++++++++++++++----------- 2 files changed, 50 insertions(+), 17 deletions(-) diff --git a/js/common/fsapi-file-transfer.js b/js/common/fsapi-file-transfer.js index 7449b852..d145a110 100644 --- a/js/common/fsapi-file-transfer.js +++ b/js/common/fsapi-file-transfer.js @@ -197,9 +197,24 @@ class FileTransferClient { // 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) { diff --git a/js/workflows/workflow.js b/js/workflows/workflow.js index ee0f1d75..3280d54e 100644 --- a/js/workflows/workflow.js +++ b/js/workflows/workflow.js @@ -10,11 +10,13 @@ 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), so 35s +// default dirty_expire_centisecs on most Linux distros is 3000 (=30s), so 40s // gives a small safety margin. Issue #229. -const HOST_FLUSH_TIMEOUT_MS = 35000; +const HOST_FLUSH_TIMEOUT_MS = 40000; // Poll interval while waiting. Keep low so we proceed quickly once the kernel -// does flush. Each poll is a tiny `os.stat()` via REPL. +// 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; /* @@ -121,9 +123,11 @@ class Workflow { // // 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. + // 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 the early-exits in _waitForHostFlushImpl. + // 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; } @@ -134,8 +138,6 @@ class Workflow { if (!fileClient.getLastWrite()) { return; } - // There IS a pending write to wait on. Show the busy overlay so the - // user knows the editor is intentionally pausing. await this.showBusy(this._waitForHostFlushImpl(fileClient)); } @@ -144,16 +146,28 @@ class Workflow { if (!pending) { return; } - const {path, byteLength} = pending; + 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 - print(os.stat('${safePath}')[6]) + _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) + print(-1, -1, -1) `; const start = Date.now(); while (Date.now() - start < HOST_FLUSH_TIMEOUT_MS) { @@ -164,14 +178,18 @@ except OSError: console.warn("Host-flush poll failed, proceeding without wait:", e); return; } - // runCode returns the REPL's stdout. We printed a single integer. - const match = String(result || "").match(/-?\d+/); + const match = String(result || "").match(/(-?\d+)\s+(-?\d+)\s+(-?\d+)/); if (match) { - const size = parseInt(match[0], 10); - if (size >= byteLength) { - // Device sees the full write; safe to proceed. Clear the - // tracker so we do not re-poll on the next softRestart - // for the same write. + 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; } From 0e06cf9e6496d08b1e8aa5d7e81179120f5f52a4 Mon Sep 17 00:00:00 2001 From: Piclaw Date: Tue, 12 May 2026 15:39:38 -0700 Subject: [PATCH 3/9] Wait for host flush on terminal Ctrl-D; document workarounds (#229) The host-flush wait was only wired into the editor's Run and Reboot button paths. A Ctrl-D typed directly in the terminal panel bypassed the wait and still raced the kernel page cache flush. Add serialTransmitWithFlushGuard() on the workflow base class. The terminal panel routes onData through it; when the user transmits a Ctrl-D (\x04) and there is a tracked pending FSAPI write, we run the same _waitForHostFlush() before passing the byte through. The fast path (no Ctrl-D or no pending write) has no extra overhead. Also add a Troubleshooting section to README documenting: - udev rule to mount CIRCUITPY with sync,flush - supervisor.runtime.autoreload = False in boot.py - vm.dirty_expire_centisecs sysctl tuning - ChromeOS limitation note Issue #229. --- README.md | 36 ++++++++++++++++++++++++++++++++++++ js/script.js | 5 ++++- js/workflows/workflow.js | 23 +++++++++++++++++++++-- 3 files changed, 61 insertions(+), 3 deletions(-) diff --git a/README.md b/README.md index 3a5312a1..b9ec1d54 100644 --- a/README.md +++ b/README.md @@ -16,6 +16,42 @@ 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. + +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).** Add a udev rule so the kernel commits writes immediately: + +``` +# /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 — Disable CircuitPython's filesystem-change auto-reload.** Add the following to `boot.py` on the device: + +```python +import supervisor +supervisor.runtime.autoreload = False +``` + +This suppresses the device's own auto-reload on filesystem change, so you fully control reboots from the editor. The editor's wait still applies to its own Run/Reboot actions. + +**Option C — Reduce the kernel's dirty-page expire window** (host-wide; affects all writes, not just CIRCUITPY): + +``` +sudo sysctl -w vm.dirty_expire_centisecs=100 +``` + +Note: ChromeOS users cannot apply Option A or C and should rely on the editor's built-in wait or use Option B on the device. + ## License This project is made available under the MIT License. For more details, see the LICENSE file in the repository. diff --git a/js/script.js b/js/script.js index 016dde36..6b353352 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 3280d54e..de91f935 100644 --- a/js/workflows/workflow.js +++ b/js/workflows/workflow.js @@ -123,8 +123,6 @@ class Workflow { // // 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. - // 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. @@ -141,6 +139,27 @@ class Workflow { 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) { From ea955f62cd6096b7759a749fd97e41d71d7c6fcf Mon Sep 17 00:00:00 2001 From: Piclaw Date: Tue, 12 May 2026 16:03:41 -0700 Subject: [PATCH 4/9] Suppress mouse-click focus outline on 'Choose a different workflow' link The 'Choose a different workflow' back link (issue #373) showed a focus rectangle after a mouse click. Use :focus / :focus-visible to suppress the outline on pointer activation while preserving a visible focus ring for keyboard users. --- sass/layout/_layout.scss | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/sass/layout/_layout.scss b/sass/layout/_layout.scss index 6ab229fa..91f2b5e8 100644 --- a/sass/layout/_layout.scss +++ b/sass/layout/_layout.scss @@ -236,6 +236,17 @@ text-decoration: underline; } + // Suppress the default focus outline on mouse-click activation. + // Keyboard users still get a visible focus ring via :focus-visible. + &:focus { + outline: none; + } + + &:focus-visible { + outline: 2px solid currentColor; + outline-offset: 2px; + } + i { margin-right: 0.35rem; } From 7ccbee965490109f3dbcecb744d9a511951a7df5 Mon Sep 17 00:00:00 2001 From: Piclaw Date: Tue, 12 May 2026 16:05:12 -0700 Subject: [PATCH 5/9] Drop focus outline entirely on workflow back link :focus-visible was still drawing the rectangle in some browsers. Match the pattern used by other anchors in the editor: no outline on any focus state. The hover underline is sufficient affordance. --- sass/layout/_layout.scss | 14 ++++++-------- 1 file changed, 6 insertions(+), 8 deletions(-) diff --git a/sass/layout/_layout.scss b/sass/layout/_layout.scss index 91f2b5e8..fa9dc221 100644 --- a/sass/layout/_layout.scss +++ b/sass/layout/_layout.scss @@ -236,15 +236,13 @@ text-decoration: underline; } - // Suppress the default focus outline on mouse-click activation. - // Keyboard users still get a visible focus ring via :focus-visible. - &:focus { + // 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; - } - - &:focus-visible { - outline: 2px solid currentColor; - outline-offset: 2px; + box-shadow: none; } i { From 0e2c6aefa9dfc8a3a86d82b070cd003ae6fa2a80 Mon Sep 17 00:00:00 2001 From: Piclaw Date: Tue, 12 May 2026 16:16:56 -0700 Subject: [PATCH 6/9] Clarify that udev sync option eliminates the editor's flush wait README Option A now explicitly notes that mounting CIRCUITPY with sync,flush makes the flush-detector poll match on its first attempt, so save/run/reboot/Ctrl-D feel instant rather than waiting up to ~30s for the kernel page cache to flush. --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index b9ec1d54..7f40262a 100644 --- a/README.md +++ b/README.md @@ -26,7 +26,7 @@ The editor mitigates this by waiting (with a Blinka spinner) for the device to c 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).** Add a udev rule so the kernel commits writes immediately: +**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 From 26bf2e3dd97ce428d7d127f3aa9fd7912f68c8c9 Mon Sep 17 00:00:00 2001 From: Piclaw Date: Tue, 12 May 2026 16:46:22 -0700 Subject: [PATCH 7/9] Drop boot.py autoreload workaround from README troubleshooting The autoreload=False suggestion was a misdirect: CircuitPython's own filesystem-change reload is a separate path that the editor's flush-detector already handles correctly. Removing it leaves the two workarounds that actually address the root kernel-flush race. --- README.md | 13 ++----------- 1 file changed, 2 insertions(+), 11 deletions(-) diff --git a/README.md b/README.md index 7f40262a..aaebd9d9 100644 --- a/README.md +++ b/README.md @@ -35,22 +35,13 @@ ACTION=="add", KERNEL=="sd[a-z]*", ATTRS{idVendor}=="239a", ENV{ID_FS_LABEL}=="C 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 — Disable CircuitPython's filesystem-change auto-reload.** Add the following to `boot.py` on the device: - -```python -import supervisor -supervisor.runtime.autoreload = False -``` - -This suppresses the device's own auto-reload on filesystem change, so you fully control reboots from the editor. The editor's wait still applies to its own Run/Reboot actions. - -**Option C — Reduce the kernel's dirty-page expire window** (host-wide; affects all writes, not just CIRCUITPY): +**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 ``` -Note: ChromeOS users cannot apply Option A or C and should rely on the editor's built-in wait or use Option B on the device. +Note: ChromeOS users cannot apply either option and should rely on the editor's built-in wait. ## License From 5b8323fc4dabdfd90919d36491ecdada15f08cfc Mon Sep 17 00:00:00 2001 From: Piclaw Date: Wed, 13 May 2026 12:19:44 -0700 Subject: [PATCH 8/9] Document 'sync' terminal one-off as Option C in README --- README.md | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index aaebd9d9..81961631 100644 --- a/README.md +++ b/README.md @@ -41,7 +41,15 @@ Then reload with `sudo udevadm control --reload-rules && sudo udevadm trigger`. sudo sysctl -w vm.dirty_expire_centisecs=100 ``` -Note: ChromeOS users cannot apply either option and should rely on the editor's built-in wait. +**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 From be252697912f39e374761054aab66ff648185222 Mon Sep 17 00:00:00 2001 From: Piclaw Date: Wed, 13 May 2026 12:23:17 -0700 Subject: [PATCH 9/9] Bump host-flush wait to 60s and document timeout in README The 40s timeout was calibrated against the default Linux dirty_expire_centisecs of 3000 (=30s), which covers a Pi 5 + SSD setup comfortably. On hosts running laptop-mode tools (which push the expire window to 60s+), on slow/contended USB buses, or when writing larger files, the 40s window could miss and fall through to the save-retry loop. Bump to 60s for headroom and call out the trade-off in the Troubleshooting section so users know to apply Options A-C if they hit the timeout regularly. --- README.md | 2 ++ js/workflows/workflow.js | 9 ++++++--- 2 files changed, 8 insertions(+), 3 deletions(-) diff --git a/README.md b/README.md index 81961631..b11d0c07 100644 --- a/README.md +++ b/README.md @@ -24,6 +24,8 @@ On Linux, the CIRCUITPY drive is mounted asynchronously by default. After the ed 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: diff --git a/js/workflows/workflow.js b/js/workflows/workflow.js index de91f935..b4726407 100644 --- a/js/workflows/workflow.js +++ b/js/workflows/workflow.js @@ -10,9 +10,12 @@ 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), so 40s -// gives a small safety margin. Issue #229. -const HOST_FLUSH_TIMEOUT_MS = 40000; +// 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)