@@ -229,8 +229,11 @@ async function newFile() {
229229
230230async 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
537547var editor ;
538- var currentTimeout = null ;
539- var saveRetryCount = 0 ;
540548const 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).
543561async 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
621690function 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
0 commit comments