Skip to content

Fix Connect/Disconnect button behavior across all workflows#490

Merged
makermelissa merged 1 commit intocircuitpython:mainfrom
makermelissa-piclaw:fix/issue-373-connect-disconnect
May 7, 2026
Merged

Fix Connect/Disconnect button behavior across all workflows#490
makermelissa merged 1 commit intocircuitpython:mainfrom
makermelissa-piclaw:fix/issue-373-connect-disconnect

Conversation

@makermelissa-piclaw
Copy link
Copy Markdown
Contributor

Closes #373.

Fixes the Connect/Disconnect button getting stuck in a wrong state across USB, Web, and BLE workflows when the underlying connection is interrupted, plus the second-click UX.

What was broken

From the issue (and tyeth's repro):

Uncaught (in promise) NetworkError: The device has been lost.
usb.js:277  Uncaught (in promise) NetworkError: The device has been lost.
script.js:592  Uncaught (in promise) NetworkError: The device has been lost.

When the board was unplugged (or the browser lost its USB grant, or the WiFi link dropped):

  1. The editor didn't always notice it was disconnected → the Disconnect button stayed visible and did nothing.
  2. Even when it did notice, clicking Connect afterwards re-prompted for a workflow instead of reconnecting to the same one.
  3. Once the user picked a workflow, there was no way to back out to the chooser without closing/reopening the dialog.

Changes

1. USB workflow — handle "device has been lost" cleanly (js/workflows/usb.js)

The unhandled rejections traced back to two places:

  • _readSerialLoop().catch(...) called onDisconnected() with the default reconnect=true. That re-entered connect() → connectToSerial() → _switchToDevice() → device.open() on a dead device, which threw another NetworkError — the one that surfaced as usb.js:277.
  • serialTransmit() had three sequential await this.writer.ready / .write / .ready calls; only two had .catch() handlers. The trailing await this.writer.ready was the source of the script.js:592 rejection (terminal onDataserialTransmit).

Fixes:

  • Read-loop catch now passes reconnect=false.
  • serialTransmit() is wrapped in a single try/catch; any failure is treated as a disconnect (onDisconnected(null, false)), so we don't keep poking a dead writer.
  • onDisconnected() is now idempotent (a small _isDisconnecting flag) — the WebSerial disconnect event and the read-loop rejection both fire on a yank, and previously both called us.
  • If any of reader.cancel() / writer.releaseLock() / serialDevice.close() themselves throw NetworkError (because the device is gone), safeReconnect is forced to false so we don't fall back through the bad reconnect path.

2. Web workflow — notice silent drops (js/workflows/web.js)

  • websocket.onerror is now wired and fires onDisconnected(event, false). Previously only onclose was handled, which doesn't reliably fire when the network goes away mid-session.
  • serialTransmit() checks websocket.readyState and bails / disconnects if it's not OPEN, instead of throwing into a try/catch and silently logging.
  • The PING_INTERVAL_MS keepalive watchdog (_checkConnection) was implemented but commented out — it's now re-enabled in onConnected(). A timed-out ping calls onDisconnected(null, false) so the UI flips back to Connect.

3. Connect remembers the last workflow (js/script.js)

  • A new webeditor.lastBackend localStorage entry is updated whenever loadWorkflow() actually loads a workflow.
  • checkConnected() now prefers that backend and goes straight to its connect dialog instead of always opening the workflow chooser.
  • A new chooseAndShowConnect() is exposed via the workflow init params (chooseConnectionFunc) so the connect dialogs can re-prompt for a workflow when the user wants to switch.

4. "Choose a different workflow" link in each connect dialog

  • index.html adds a <p class="connect-back-row"><a class="connect-back">… Choose a different workflow</a></p> to the BLE / Web / USB connect modals.
  • Workflow._wireBackToChooser(modal) (new helper on the base class) attaches the click handler idempotently. Each subclass's showConnect() calls it after getModal().
  • A small block in sass/layout/_layout.scss styles the back link without shifting the existing modal layout.

Testing notes

I'd suggest verifying:

  • USB: connect → unplug board → Disconnect button flips to Connect, no NetworkError spam in console. Click Connect → goes straight back to the USB connect dialog.
  • Web: connect to a board over WiFi → kill the network briefly → UI flips to Connect within ~5–10 s (ping watchdog).
  • USB → Disconnect → click Connect → USB dialog re-opens; click "Choose a different workflow" → workflow chooser appears.
  • BLE: a BLE-specific reconnect path was deliberately left alone (the existing gattserverdisconnected handler + the dialog's Reconnect button are how BLE was already behaving). Happy to follow up with a BLE-specific tightening if you'd like.

Notes for review

  • The webeditor.lastBackend value is only used as a hint — it never auto-attempts a hardware connection on its own; it just routes the user to the matching connect dialog. URL-supplied ?backend=… still wins (handled in getBackend()).
  • _isDisconnecting is reset in a finally, so a future failure inside the base super.onDisconnected() won't permanently jam the workflow.
  • No new dependencies. npm run build passes locally.

Closes circuitpython#373.

Three related problems were causing the Connect/Disconnect button to
get stuck in a wrong state when the underlying device went away:

1. The USB read loop / writer surfaced "NetworkError: The device has
   been lost." as unhandled promise rejections (usb.js:277,
   script.js:592 in the issue) when a board was unplugged or its USB
   grant was revoked. The read-loop catch path also called
   onDisconnected() with the default reconnect=true, which re-entered
   connect() on a now-dead device and triggered another NetworkError.

   - serialTransmit() now wraps the writer awaits in a single try/catch
     and treats any failure as a disconnect (reconnect=false) instead
     of letting the rejection escape.
   - The read loop's catch handler now passes reconnect=false.
   - onDisconnected() is now idempotent (guards against the gatt-style
     double-fire from "disconnect" event + read-loop rejection) and
     downgrades reconnect to false if any of the underlying close calls
     also throw NetworkError.

2. The Web workflow had its keepalive ping commented out and never
   reacted to websocket errors, so a silent network drop left the UI
   stuck on "Disconnect".

   - websocket.onerror now fires onDisconnected(event, false).
   - serialTransmit() bails early (and disconnects) if the socket is
     not in OPEN state.
   - The PING_INTERVAL_MS watchdog is re-enabled in onConnected().

3. Clicking Connect after a disconnect always re-prompted for a
   workflow.

   - The most recently chosen backend is persisted in localStorage
     ("webeditor.lastBackend") whenever loadWorkflow() runs.
   - checkConnected() prefers that backend and goes straight to its
     showConnect() dialog. Each connect dialog now has a "Choose a
     different workflow" link that re-opens the workflow chooser, so
     the user is never trapped in the auto-selected workflow.

The new chooser-back link is wired generically via Workflow._wireBack
ToChooser(), called from each subclass's showConnect(); the markup
lives in index.html with the .connect-back class and minimal styling
in sass/layout/_layout.scss.
Copy link
Copy Markdown
Collaborator

@makermelissa makermelissa left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Tested and working perfectly.

@makermelissa makermelissa merged commit eb3b9bd into circuitpython:main May 7, 2026
1 check passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Get Connect/Disconnect Button to work properly

2 participants