From fd520ec514e58fe93fd5e6c8a72d99338c9a6e93 Mon Sep 17 00:00:00 2001 From: Elias Bakken Date: Tue, 11 Aug 2026 20:58:04 +0200 Subject: [PATCH] Fix WiFi setup flow leaving the board unreachable on failure (#90) Two real gaps, per the issue: 1. wifi-connect never restored the hotspot AP if the station-mode connection failed (bad password, AP out of range, DHCP timeout) - the board was left in station mode with no working connection and no hotspot, unreachable until physical intervention. It now falls back to the Recore hotspot on any connection failure, live-tested against a nonexistent SSID. 2. The frontend polled the pre-switch origin for the outcome indefinitely. Once the board tears down its own hotspot to switch networks, that origin is gone for good regardless of outcome, so polling it can never observe a result. Bounded to a 15s window, after which the UI shows guidance to reconnect and continue at recore.local instead of spinning forever. Also added a persistent status line so the page shows what's actually happening at each step instead of relying on toast notifications alone. Both fixes required a global axios timeout (client/src/main.js): several other polling loops (WiFi status, transfer progress, ...) had no per-call timeout, and a request against a dead origin can hang completely silently with no error ever firing (confirmed live, same root cause as #95) - enough of those piling up at once starves the browser's small same-origin connection pool, so even a request with its own timeout can't get a socket to run on. A global default bounds every request that doesn't set its own. Also fixes a related first-boot race in expand-usb found while testing this: fdisk's own in-place partition table re-read can fail with "Device or resource busy" even though the write itself succeeded - the kernel picks up the new partition via udev shortly after regardless, so wait for the device node instead of trusting fdisk's exit code. Closes #90 --- bin/prod/expand-usb | 18 ++++++++- bin/prod/wifi-connect | 13 ++++++ client/src/components/TheWifiSetup.vue | 55 ++++++++++++++++++++++++-- client/src/main.js | 12 ++++++ client/vue.config.js | 5 +++ test/bats/wifi.bats | 42 ++++++++++++++++++++ 6 files changed, 141 insertions(+), 4 deletions(-) diff --git a/bin/prod/expand-usb b/bin/prod/expand-usb index ce1c9ee..caa9650 100755 --- a/bin/prod/expand-usb +++ b/bin/prod/expand-usb @@ -15,7 +15,23 @@ if [ -b /dev/sda2 ]; then fi info "Creating partition on unused space" -printf "n\n\n\n\nw\n" | fdisk /dev/sda +# fdisk's own in-place re-read of the table can fail with "Device or +# resource busy" even though the write itself succeeded (seen live on a +# fresh USB drive) - the kernel picks up the new partition via udev +# shortly after regardless, so don't treat that as fatal; wait for the +# device node to actually appear instead of trusting fdisk's exit code. +printf "n\n\n\n\nw\n" | fdisk /dev/sda || true + +info "Waiting for /dev/sda2 to appear" +for i in $(seq 1 10); do + [ -b /dev/sda2 ] && break + sleep 1 +done + +if [ ! -b /dev/sda2 ]; then + info "Partition 2 did not appear after creation" + exit 1 +fi info "Creating ext4 filesystem" mkfs.ext4 -F -E nodiscard /dev/sda2 diff --git a/bin/prod/wifi-connect b/bin/prod/wifi-connect index 64a34a5..98800d6 100755 --- a/bin/prod/wifi-connect +++ b/bin/prod/wifi-connect @@ -5,6 +5,7 @@ INTERFACE="${WIFI_INTERFACE:-wlan0}" SYS_NET="${SYS_NET:-/sys/class/net}" IWD_DIR="${IWD_DIR:-/var/lib/iwd}" LOG_FILE="${LOG_FILE:-/var/log/reflash.log}" +AP_PROFILE="Recore" SSID="$1" PASS="$2" @@ -14,6 +15,16 @@ info() { echo "$1" } +# A failed connection here (bad password, AP out of range, DHCP timeout) +# would otherwise leave the board in station mode with no working +# connection and no hotspot - unreachable until physical intervention (#90). +restore_hotspot() { + info "Connection failed - restoring hotspot ($AP_PROFILE) so the board stays reachable." + iwctl ap "$INTERFACE" stop 2>/dev/null + iwctl device "$INTERFACE" set-property Mode ap + iwctl ap "$INTERFACE" start-profile "$AP_PROFILE" 2>/dev/null +} + if [ -z "$SSID" ] || [ -z "$PASS" ]; then echo "Usage: $0 " exit 1 @@ -66,6 +77,7 @@ for i in {1..3}; do sleep 2 if [ $i -eq 3 ]; then info "Connection command failed after 3 attempts." + restore_hotspot exit 1 fi done @@ -85,5 +97,6 @@ while [ $COUNT -lt $MAX_RETRIES ]; do done info "Timed out waiting for IP address." +restore_hotspot exit 1 diff --git a/client/src/components/TheWifiSetup.vue b/client/src/components/TheWifiSetup.vue index f3057cc..618e8de 100644 --- a/client/src/components/TheWifiSetup.vue +++ b/client/src/components/TheWifiSetup.vue @@ -45,6 +45,11 @@

⚠️ No WiFi dongle detected. Please plug in a USB WiFi adapter.

+ +
+

{{ statusMessage }}

+ Continue at recore.local +
@@ -72,6 +77,9 @@ export default { availableAPs: [], selected: null, progressVisible: false, + connectPollDeadline: 0, + networkSwitching: false, + statusMessage: "", }), computed: mapGetters(["options"]), methods: { @@ -100,11 +108,13 @@ export default { }, async startWifiScan() { this.progressVisible = true; + this.statusMessage = "Scanning for networks..."; try { await axios.post('/api/wifi_start_scan'); setTimeout(this.pollScanResults, 1000); } catch (err) { this.progressVisible = false; + this.statusMessage = "Could not start scan."; } }, async pollScanResults() { @@ -118,7 +128,7 @@ export default { for (const ap in this.availableAPs) { this.availableAPs[ap].label = this.availableAPs[ap].SSID + " " + this.availableAPs[ap].signal; } - this.progressVisible = false; + this.statusMessage = `Found ${this.availableAPs.length} network(s).`; } } catch (err) { setTimeout(this.pollScanResults, 1000); @@ -130,6 +140,9 @@ export default { return; } this.progressVisible = true; + this.networkSwitching = false; + this.connectPollDeadline = Date.now() + 15000; + this.statusMessage = `Connecting to ${this.selected.SSID}...`; try { await axios.post('/api/wifi_start_connect',{ SSID: this.selected.SSID, @@ -139,33 +152,67 @@ export default { } catch (err) { const msg = err.response?.data || "Could not start connection"; this.$waveui.notify(msg, "error", 4000); + this.statusMessage = msg; this.progressVisible = false; } }, async pollConnectResults() { + // Once the board switches to station mode it tears down its own + // hotspot AP - if that's the network this page loaded from, its + // origin is gone for good from here on, regardless of whether the + // station-mode connection ultimately succeeds (#90). Polling that + // dead origin can never observe an outcome, so only do it for a + // short window to catch fast, local failures (e.g. validation + // errors) that happen before the switch - after that, stop and + // point the user at reconnecting manually instead of spinning + // forever against an address that will never respond again. + // + // A deadline (not an attempt counter) bounds this, because a + // request against a dead origin doesn't necessarily fail quickly - + // confirmed live (#95) that a request can hang completely silently + // (no error, ever) once the interface disappears. The explicit + // timeout below turns that into a normal rejection so the loop + // keeps moving instead of getting stuck forever on one request. + if (Date.now() > this.connectPollDeadline) { + this.progressVisible = false; + this.networkSwitching = true; + this.statusMessage = `The board is switching to ${this.selected.SSID}. Reconnect this device to that network, then continue below.`; + return; + } try { - const res = await axios.get('/api/wifi_poll_connect'); + const res = await axios.get('/api/wifi_poll_connect', { timeout: 3000 }); if (res.status === 204) { setTimeout(this.pollConnectResults, 1000); } else { console.log(res.data) if(res.data.isConnecting == true){ + this.statusMessage = `Still connecting to ${this.selected.SSID}...`; setTimeout(this.pollConnectResults, 1000); } else { if (res.data.error) { this.$waveui.notify(res.data.error, "error", 0); + this.statusMessage = res.data.error; } else{ - this.$waveui.notify("Connected to "+this.selected.SSID, "info", 0); + this.$waveui.notify("Connected to "+this.selected.SSID, "info", 0); + this.statusMessage = `Connected to ${this.selected.SSID}.`; } this.progressVisible = false; } } } catch (err) { + // A hung/timed-out request here looks the same as a normal + // network blip - keep showing the same "still connecting" + // status rather than alarming the user prematurely. The + // deadline check above is what actually decides when to give up. + this.statusMessage = `Still connecting to ${this.selected.SSID}...`; setTimeout(this.pollConnectResults, 1000); } }, + continueToBoard() { + window.location.href = "http://recore.local/"; + }, async getWifiStatus() { try { const response = await axios.get('/api/get_wifi_status'); @@ -186,6 +233,8 @@ export default { immediate: true, handler(is_open) { if (is_open) { + this.networkSwitching = false; + this.statusMessage = ""; this.getWifiStatus() this.dialog.show = true; } diff --git a/client/src/main.js b/client/src/main.js index 9fbc91c..41d5b0b 100644 --- a/client/src/main.js +++ b/client/src/main.js @@ -1,10 +1,22 @@ import { createApp } from 'vue' import WaveUI from 'wave-ui' +import axios from 'axios' import App from './App.vue' import store from './store' import 'wave-ui/dist/wave-ui.css' +// Several components poll the board on a timer with no per-call timeout +// (WiFi status, transfer progress, scan results, ...). A request with no +// timeout that never resolves - confirmed live: the board's WiFi +// interface disappearing mid-request during an AP/station switch (#90, +// #95) causes exactly this - sits holding one of the browser's ~6 +// same-origin connection slots forever. Enough of those piling up at +// once starves every other request, including ones with their own +// explicit timeout, since they can't get a socket to run on at all. A +// global default bounds every request that doesn't set its own. +axios.defaults.timeout = 10000 + const app = createApp(App) app.use(store) diff --git a/client/vue.config.js b/client/vue.config.js index 1a7ac06..20ca43a 100644 --- a/client/vue.config.js +++ b/client/vue.config.js @@ -1,5 +1,10 @@ module.exports = { transpileDependencies: true, + // The board's rootfs is a small, tightly-sized initrd ramdisk (368M + // total) - sourcemaps add 3MB+ per build for a debugging aid that's + // not needed on-device, and repeated deploys without them were + // enough to fill the disk outright during testing. + productionSourceMap: false, devServer: { proxy: { '^/api': { diff --git a/test/bats/wifi.bats b/test/bats/wifi.bats index 9cc6228..90efe12 100644 --- a/test/bats/wifi.bats +++ b/test/bats/wifi.bats @@ -123,3 +123,45 @@ EOF assert_called_with "station wlan0 connect HomeNet" [[ "$output" == *"Connected with IP: 192.168.1.50/24"* ]] } + +# --- connect failure -> hotspot fallback (#90) ------------------------------ + +@test "wifi-connect: restores hotspot if the connect command keeps failing" { + with_adapter + cat > "$SHIMDIR/iwctl" <<'EOF' +#!/usr/bin/env bash +echo "iwctl $*" >> "$CALLS" +if [ "$1 $2 $3" = "device wlan0 show" ]; then echo "Mode station"; fi +if [ "$1 $2 $3" = "station wlan0 connect" ]; then exit 1; fi +exit 0 +EOF + chmod +x "$SHIMDIR/iwctl" + run "$PROD_BIN/wifi-connect" HomeNet hunter2 + [ "$status" -eq 1 ] + [[ "$output" == *"Connection command failed after 3 attempts."* ]] + assert_called_with "device wlan0 set-property Mode ap" + assert_called_with "ap wlan0 start-profile Recore" +} + +@test "wifi-connect: restores hotspot if DHCP never leases" { + with_adapter + cat > "$SHIMDIR/iwctl" <<'EOF' +#!/usr/bin/env bash +echo "iwctl $*" >> "$CALLS" +if [ "$1 $2 $3" = "device wlan0 show" ]; then echo "Mode station"; fi +exit 0 +EOF + chmod +x "$SHIMDIR/iwctl" + cat > "$SHIMDIR/ip" <<'EOF' +#!/usr/bin/env bash +echo "ip $*" >> "$CALLS" +# No "inet " line in the output - no lease ever arrives. +exit 0 +EOF + chmod +x "$SHIMDIR/ip" + run "$PROD_BIN/wifi-connect" HomeNet hunter2 + [ "$status" -eq 1 ] + [[ "$output" == *"Timed out waiting for IP address."* ]] + assert_called_with "device wlan0 set-property Mode ap" + assert_called_with "ap wlan0 start-profile Recore" +}