What
When the route in use stops working, the web client now tries the environment's other saved routes and reconnects through the first one that reaches it. When none does, the shell says why in plain terms instead of a generic "Environment unreachable".
- Fallback without duplication. Only the route in use changes. The environment record, its token and its sessions stay the same; the socket gets a new URL and keeps the same store.
- Localhost first. Routes are tried local (loopback) first, then the rest in the person's order. A reload or reselection starts from the top again, so a local server that comes back is preferred again.
- Your order stays yours. The route in use is held in memory, separate from the saved priority, so falling back to a tunnel for an hour never reorders your routes. Use still sets the first choice and switches to it.
- Reconnect. While no route answers, every route is asked again after 2 s, 4 s, 8 s, 15 s, then every 30 s, and the client reconnects on its own when one answers. Retry asks immediately.
What triggers a search
| Trigger | What happens |
|---|---|
| Bootstrap on the route in use fails, is refused, or answers as another environment | The other routes are tried; the banner reads "Trying another route". |
| The live socket drops | The route in use is checked on its own first. If it still answers, it is a blip and the socket's own backoff handles it. If not, the others are tried. |
| No route answered last time | All routes are asked again, quietly, on the backoff above. |
No search runs while a typed connect is in flight, while the device is offline, or after the environment refused the token.
The reasons shown
| Reason | Learned from | Banner/screen |
|---|---|---|
| Environment offline | Nothing answers on a loopback route, or a gateway answers 502/503/504 (a tunnel up with nothing behind it) | "Environment offline" |
| Route refused access | 401/403 on /bootstrap (which takes no token) | "Route refused access" |
| Route unavailable | Silence over a network, Cloudflare's 530, other HTTP errors | "Route unavailable" (says the environment may still be running) |
| Different environment | The address answers as another environment | "Environment unreachable" |
| Token rejected | The socket is refused with auth | "Not authorized"; no fallback or retry, since every route sends the same token |
The socket's auth refusal previously never reached the connection UI (it stayed on "ready" with no client); it now shows the "Not authorized" screen. Each state has a card at /playground/connection.
Safety invariants kept
- A route found by the search is only made the route in use. The ordinary bootstrap then verifies it answers as this environment before any socket (which carries the token) is opened on it.
- A route answering as another environment is never adopted.
- Typed connects and forgotten routes behave as before.
Decisions made without asking
- The route in use is kept per page, not persisted; a reload goes back to local first.
- No automatic switch back to a higher route while a fallback route is working, because moving a live connection is a disruption. The switch happens on the next reload, reselection or failure.
- A 401/403 on
/bootstrapis treated as route-level and falls back. A socketauthrefusal is environment-level and does not.
Known gaps
- A route whose bootstrap answers but whose WebSocket upgrade never gets through (a proxy that blocks upgrades) counts as a blip and keeps retrying the socket; it is not given up on.
- Our server's origin-refusal 403 carries no CORS headers, so a browser reads it as silence. On localhost it shows as "Environment offline", and the wording mentions checking the page's address. A server fix is filed separately.
Review
Three rounds, each fix re-reviewed:
- gpt-6.1-sol and Fable found two P2 races and four lower issues. The races: a recovered socket torn down by a stale search result, and coming back online clearing a refused token. All six are fixed in
ced56fa. - A second gpt-6.1-sol pass found two more automatic paths that cleared the refused-token state. In
55fde94the state became a latch: only a person acting on that environment (Retry, Use, connect, select, change environment) or the socket getting through clears it, and no search starts while it holds. - A third pass (Fable) found two remaining edge cases, both fixed in
33ef9fa. A typed connect cleared the latch before the connect existed, and removing a different environment cleared it too.
Each race and latch fix has a regression test that fails without the fix.
Tests: route-fallback.test.ts (classification, ranking, search order), connection-state reason copy, store helpers, and provider integration tests. The integration tests cover fallback, local preference, socket drop, blip, refused token, outage then recovery, and every race above. Web suite 224/224; typecheck and lint clean.
Checked in the real app with a local route and a tunnel route both down: local is tried first and the banner reads "Environment offline".
🤖 Generated with Claude Code
[Medium risk] Adds route fallback logic when environments become unreachable.
The PR appears safe to merge; both previous findings are fixed and no new actionable issue was identified.
Summary
The PR adds local-first route fallback and retry, keeps the active route separate from saved preference, and explains connection failures. Since the previous review, it also adds Make first for a fallback route and gives a different-environment response priority over an unavailable route. Both previous findings are fixed.
Diagram
Reviews (2) · Last reviewed commit: "fix(web): let a route that took over bec..."