Skip to content

Commit 3df0be3

Browse files
authored
Merge pull request #498 from makermelissa-piclaw/fix/issue-460-save-run-await
Honor save failures and surface USB-MSC hint when the filesystem is locked
2 parents f7f21b4 + bfd3c90 commit 3df0be3

4 files changed

Lines changed: 202 additions & 51 deletions

File tree

js/common/web-file-transfer.js

Lines changed: 56 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -50,9 +50,36 @@ class FileTransferClient {
5050
}
5151
}
5252

53+
// Build a ProtocolError-shaped error that callers can recognize as
54+
// "the device's filesystem is currently held by something else"
55+
// (typically USB MSC). Tagged identically to the runtime PUT 409/500
56+
// path so `saveFileContents()` can show the same actionable dialog
57+
// whether the check trips on the cached writable flag or on the
58+
// actual response from the device.
59+
_writeProtectedError() {
60+
const err = new ProtocolError("File System is Read Only.");
61+
err.status = 409;
62+
err.writeProtected = true;
63+
err.hint = "The board's filesystem is currently locked, " +
64+
"usually because CIRCUITPY is mounted on a " +
65+
"computer over USB. Disconnect the USB cable, " +
66+
"or disable USB Mass Storage in boot.py, then " +
67+
"reset the board and try saving again. " +
68+
"(Ejecting the drive in your OS may not be " +
69+
"enough on its own.)";
70+
err.helpUrl = "https://learn.adafruit.com/getting-started-with-web-workflow-using-the-code-editor/device-setup#disabling-usb-mass-storage-3125964";
71+
err.helpLabel = "Disabling USB Mass Storage (Adafruit Learn)";
72+
return err;
73+
}
74+
5375
async _checkWritable() {
76+
// Force a re-read of the writable flag so the user can recover
77+
// without disconnecting: if they just released the drive (or
78+
// disabled USB MSC and reset), the next save attempt should
79+
// succeed, not bounce off a stale `false` cache.
80+
this._writable = null;
5481
if (await this.readOnly()) {
55-
throw new Error("File System is Read Only. Try disabling the USB Drive.");
82+
throw this._writeProtectedError();
5683
}
5784
}
5885

@@ -72,7 +99,8 @@ class FileTransferClient {
7299
options.headers['Content-Type'] = "application/octet-stream";
73100
}
74101

75-
await this._fetch(`/fs${path}`, options);
102+
const response = await this._fetch(`/fs${path}`, options);
103+
return response.ok;
76104
}
77105

78106
// Makes the directory and any missing parents
@@ -120,7 +148,32 @@ class FileTransferClient {
120148
}
121149

122150
if (!response.ok) {
123-
throw new ProtocolError(response.statusText);
151+
// Attach the status code + a friendly hint when we recognize
152+
// the failure mode, so callers can branch on it (e.g. show an
153+
// actionable message and skip retries that won't help).
154+
const err = new ProtocolError(response.statusText || `HTTP ${response.status}`);
155+
err.status = response.status;
156+
err.method = (fetchOptions.method || "GET").toUpperCase();
157+
err.path = location;
158+
// /fs/ PUT against a write-protected filesystem currently returns
159+
// 500 on shipped CircuitPython firmware. A fix is pending to
160+
// return 409 Conflict (matching DELETE / MOVE / mkdir-PUT in
161+
// the same file). Treat both the same way until enough users
162+
// are on the patched firmware that 500 can be left generic.
163+
const isFsWrite = err.method === "PUT" &&
164+
typeof location === "string" &&
165+
location.startsWith("/fs/");
166+
if (isFsWrite && (response.status === 409 || response.status === 500)) {
167+
// Reuse the same wording/hint as the cached-flag
168+
// _checkWritable() path, so users see one consistent
169+
// message regardless of which layer caught the lock.
170+
const wp = this._writeProtectedError();
171+
err.writeProtected = true;
172+
err.hint = wp.hint;
173+
err.helpUrl = wp.helpUrl;
174+
err.helpLabel = wp.helpLabel;
175+
}
176+
throw err;
124177
}
125178

126179
return response;

js/script.js

Lines changed: 112 additions & 45 deletions
Original file line numberDiff line numberDiff line change
@@ -229,8 +229,11 @@ async function newFile() {
229229

230230
async function saveRunFile() {
231231
if (await checkConnected()) {
232+
// workflow.saveFile() now propagates the real save result -- only
233+
// soft-restart / re-import once the PUT actually succeeded. Otherwise
234+
// we would reboot the board running the old code.py while the editor
235+
// still had the unsaved edits (issue #460).
232236
if (await workflow.saveFile()) {
233-
setSaved(true);
234237
await workflow.runCurrentCode();
235238
}
236239
}
@@ -328,7 +331,14 @@ async function checkReadOnly() {
328331
await showMessage(readOnly);
329332
return false;
330333
} else if (readOnly) {
331-
await showMessage("Warning: File System is in read only mode. Disable the USB drive to allow write access.");
334+
// Concise connect-time notice that the filesystem is read-only,
335+
// with a link to the Learn guide for users who want the fix now.
336+
const learnUrl = "https://learn.adafruit.com/getting-started-with-web-workflow-using-the-code-editor/device-setup#disabling-usb-mass-storage-3125964";
337+
await showMessage(
338+
"Filesystem is read-only — you can browse files, but saving " +
339+
"will fail until USB Mass Storage is released. " +
340+
`<a href="${learnUrl}" target="_blank" rel="noopener noreferrer">How to fix</a>.`
341+
);
332342
}
333343
return true;
334344
}
@@ -535,47 +545,110 @@ async function loadEditor() {
535545
}
536546

537547
var editor;
538-
var currentTimeout = null;
539-
var saveRetryCount = 0;
540548
const MAX_SAVE_RETRIES = 3;
549+
const SAVE_RETRY_DELAY_MS = 2000;
550+
let saveInFlight = false;
541551

542-
// Save the File Contents and update the UI
552+
function sleep(ms) {
553+
return new Promise((resolve) => setTimeout(resolve, ms));
554+
}
555+
556+
// Save the File Contents and update the UI. Returns true on success, false
557+
// on final failure (after all retries). Retries inline so callers (Save+Run,
558+
// hotkeys, dialogs) can actually await the outcome -- previously this used
559+
// a fire-and-forget setTimeout, which let Save+Run soft-restart the board
560+
// before the PUT had succeeded (issue #460).
543561
async function saveFileContents(path) {
544-
// If this is a different file, we write everything
545-
if (path !== workflow.currentFilename) {
546-
unchanged = 0;
547-
}
548-
let doc = editor.state.doc;
549-
let offset = 0;
550-
let contents = doc.sliceString(0);
551-
if (workflow.partialWrites) {
552-
offset = unchanged;
553-
console.log("sync starting at", unchanged, "to", editor.state.doc.length);
562+
if (saveInFlight) {
563+
// Re-entrant save (e.g. user mashing Ctrl-S / Save+Run). The first
564+
// call will report success/failure; the second would race the same
565+
// bytes onto the wire and confuse partialWrites bookkeeping.
566+
console.log("saveFileContents: already in flight, ignoring re-entry");
567+
return false;
554568
}
555-
let oldUnchanged = unchanged;
556-
unchanged = doc.length;
569+
saveInFlight = true;
557570
try {
558-
if (await workflow.writeFile(path, contents, offset)) {
559-
setFilename(workflow.currentFilename);
560-
setSaved(true);
561-
saveRetryCount = 0;
562-
} else {
563-
await showMessage(`Saving file '${workflow.currentFilename}' failed.`);
564-
}
565-
} catch (e) {
566-
console.error("write failed", e, e.stack);
567-
unchanged = Math.min(oldUnchanged, unchanged);
568-
if (currentTimeout != null) {
569-
clearTimeout(currentTimeout);
571+
// If this is a different file, we write everything
572+
if (path !== workflow.currentFilename) {
573+
unchanged = 0;
570574
}
571-
saveRetryCount++;
572-
if (saveRetryCount < MAX_SAVE_RETRIES) {
573-
console.log(`Save retry ${saveRetryCount} of ${MAX_SAVE_RETRIES}...`);
574-
currentTimeout = setTimeout(() => saveFileContents(path), 2000);
575-
} else {
576-
saveRetryCount = 0;
577-
await showMessage(`Saving file '${workflow.currentFilename}' failed after multiple attempts. Check your connection and try again.`);
575+
let doc = editor.state.doc;
576+
let contents = doc.sliceString(0);
577+
let baseUnchanged = unchanged;
578+
let docLengthAtStart = doc.length;
579+
580+
for (let attempt = 1; attempt <= MAX_SAVE_RETRIES; attempt++) {
581+
// Recompute offset each attempt -- if onTextChange fired between
582+
// retries, `unchanged` may have shrunk and we need to resend more.
583+
let offset = 0;
584+
if (workflow.partialWrites) {
585+
offset = Math.min(baseUnchanged, unchanged);
586+
console.log("sync starting at", offset, "to", editor.state.doc.length);
587+
}
588+
// Optimistically mark the bytes-being-sent as unchanged. If the
589+
// write throws we'll roll back to baseUnchanged for the next try.
590+
unchanged = docLengthAtStart;
591+
try {
592+
if (await workflow.writeFile(path, contents, offset)) {
593+
setFilename(workflow.currentFilename);
594+
setSaved(true);
595+
return true;
596+
}
597+
// writeFile returned a falsy value without throwing -- treat
598+
// as a soft failure and surface a message immediately.
599+
await showMessage(`Saving file '${workflow.currentFilename}' failed.`);
600+
setSaved(false);
601+
return false;
602+
} catch (e) {
603+
console.error(`write failed (attempt ${attempt} of ${MAX_SAVE_RETRIES})`, e, e.stack);
604+
unchanged = Math.min(baseUnchanged, unchanged);
605+
// If the device cleanly told us the filesystem is held by
606+
// someone else (most commonly USB-MSC: the host has
607+
// CIRCUITPY mounted), retrying won't help -- surface an
608+
// actionable hint immediately and bail. Older CircuitPython
609+
// firmware returns 500 for this case, newer firmware
610+
// returns 409 Conflict; web-file-transfer.js tags both
611+
// with `writeProtected` so we can treat them the same way.
612+
if (e && e.writeProtected) {
613+
setSaved(false);
614+
const learnUrl = e.helpUrl || "https://learn.adafruit.com/getting-started-with-web-workflow-using-the-code-editor/device-setup#disabling-usb-mass-storage-3125964";
615+
const learnLabel = e.helpLabel || "Disabling USB Mass Storage (Adafruit Learn)";
616+
// MessageModal renders via innerHTML, so real markup
617+
// (sections, list, link) is fine. Sections separate the
618+
// 'what happened', 'why', and 'how to fix' so users can
619+
// scan instead of parsing a wall of prose.
620+
await showMessage(
621+
`<p><strong>Could not save '${workflow.currentFilename}'.</strong></p>` +
622+
`<p>The board's filesystem is locked, usually because ` +
623+
`CIRCUITPY is mounted on a computer over USB.</p>` +
624+
`<p><strong>To fix:</strong></p>` +
625+
`<ul style="margin: 0.25em 0 0.5em 1.25em; padding: 0;">` +
626+
`<li>Disconnect the USB cable, <em>or</em></li>` +
627+
`<li>Disable USB Mass Storage in <code>boot.py</code>, then reset the board.</li>` +
628+
`</ul>` +
629+
`<p><em>Note:</em> ejecting the drive in your OS isn't always enough on its own.</p>` +
630+
`<p><a href="${learnUrl}" target="_blank" rel="noopener noreferrer">${learnLabel}</a></p>` +
631+
`<p>Your edits are still here — save again once the filesystem is writable.</p>`
632+
);
633+
return false;
634+
}
635+
if (attempt < MAX_SAVE_RETRIES) {
636+
await sleep(SAVE_RETRY_DELAY_MS);
637+
// Bail out if the user disconnected mid-retry.
638+
if (!workflow || !workflow.connectionStatus()) {
639+
setSaved(false);
640+
return false;
641+
}
642+
}
643+
}
578644
}
645+
// All retries exhausted. Leave the editor marked dirty so the user
646+
// knows the file on the board is still stale.
647+
setSaved(false);
648+
await showMessage(`Saving file '${workflow.currentFilename}' failed after multiple attempts. Check your connection and try again.`);
649+
return false;
650+
} finally {
651+
saveInFlight = false;
579652
}
580653
}
581654

@@ -611,19 +684,13 @@ async function onTextChange(update) {
611684
unchanged = 0;
612685
}
613686

614-
if (currentTimeout != null) {
615-
clearTimeout(currentTimeout);
616-
}
617-
618687
setSaved(false);
619688
}
620689

621690
function disconnectCallback() {
622-
if (currentTimeout != null) {
623-
clearTimeout(currentTimeout);
624-
currentTimeout = null;
625-
}
626-
saveRetryCount = 0;
691+
// saveInFlight is intentionally not forced here -- the in-flight
692+
// saveFileContents loop checks connectionStatus() between retries and
693+
// exits cleanly on its own, then clears the flag in its finally block.
627694
updateUIConnected(false);
628695
}
629696

js/workflows/workflow.js

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -442,8 +442,14 @@ except ImportError:
442442
// canceled or rejected) is treated the same as null and does not get
443443
// forwarded to writeFile, where it would crash in _splitPath. See #327.
444444
if (path != null) {
445-
await this._saveFileContents(path);
446-
return true;
445+
// Propagate the actual save result so Save+Run and other callers
446+
// can avoid taking follow-up actions (soft-restart, import) when
447+
// the underlying PUT failed. _saveFileContents returns false on
448+
// exhausted retries; treating only an explicit `false` as failure
449+
// keeps backwards compatibility with older saveFileFunc callbacks
450+
// that returned undefined on success (issue #460).
451+
const result = await this._saveFileContents(path);
452+
return result !== false;
447453
}
448454
return false;
449455
}

sass/layout/_layout.scss

Lines changed: 26 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -318,7 +318,32 @@
318318
display: none;
319319

320320
&.prompt {
321-
max-height: 365px;
321+
max-height: 80vh;
322+
// Flex column so the buttons stay pinned to the bottom and the
323+
// content area can scroll when a dialog has rich/multi-section
324+
// markup that overflows the modal height. Three-class selector
325+
// beats `.popup-modal.is--visible` so the `display: flex`
326+
// wins on specificity.
327+
&.is--visible {
328+
display: flex;
329+
flex-direction: column;
330+
}
331+
#message {
332+
overflow-y: auto;
333+
flex: 1 1 auto;
334+
min-height: 0;
335+
}
336+
.buttons {
337+
flex: 0 0 auto;
338+
}
339+
}
340+
341+
// The message dialog often shows multi-section explanatory prose
342+
// (e.g. the save-failure / read-only-filesystem help). Cap the
343+
// width so paragraphs wrap at a comfortable reading measure rather
344+
// than stretching the full viewport on a wide monitor.
345+
&[data-popup-modal="message"] {
346+
max-width: min(480px, 90vw);
322347
}
323348

324349
&.shadow {

0 commit comments

Comments
 (0)