diff --git a/docs/flasher/index.html b/docs/flasher/index.html
index 50aa1766..40d429b5 100644
--- a/docs/flasher/index.html
+++ b/docs/flasher/index.html
@@ -354,6 +354,7 @@
let firmwarePathPrefix = null;
let selectedReleaseMetadata = null;
let customFirmwareBytes = null;
+ let customFirmwareSelectionToken = 0;
// Track whether the selected release has all assets for full install
let releaseHasFullAssets = false;
@@ -560,19 +561,31 @@
}
customFirmwareInput.addEventListener('change', async function() {
+ const selectionToken = ++customFirmwareSelectionToken;
+ customFirmwareBytes = null;
+ firmwarePathPrefix = null;
+ selectedReleaseMetadata = null;
+ releaseHasFullAssets = false;
+ flashBtn.disabled = true;
+ eraseCheckbox.checked = false;
+ eraseCheckbox.disabled = true;
+ versionSelect.selectedIndex = 0;
+
const file = this.files?.[0];
- if (!file) return;
- setCustomFirmwareStatus('Reading firmware.bin...');
+ if (!file) {
+ setCustomFirmwareStatus('No custom firmware selected.');
+ return;
+ }
+ document.getElementById('firmware-version').textContent = file.name;
+ setCustomFirmwareStatus(`Reading firmware.bin: ${file.name} (${file.size} bytes)...`);
try {
const bytes = new Uint8Array(await file.arrayBuffer());
+ if (selectionToken !== customFirmwareSelectionToken) return;
await validateCustomFirmware(bytes);
+ if (selectionToken !== customFirmwareSelectionToken) return;
const hash = await sha256Hex(bytes);
+ if (selectionToken !== customFirmwareSelectionToken) return;
customFirmwareBytes = bytes;
- firmwarePathPrefix = null;
- selectedReleaseMetadata = null;
- versionSelect.selectedIndex = 0;
- eraseCheckbox.checked = false;
- eraseCheckbox.disabled = true;
versionNote.textContent = `Custom firmware update only — ${bytes.byteLength} bytes — SHA-256 ${hash}`;
versionNote.classList.remove('hidden');
document.getElementById('firmware-version').textContent = file.name;
@@ -581,9 +594,10 @@
const serialWarning = navigator.serial ? '' : ' Web Serial is unavailable in this browser context.';
setCustomFirmwareStatus(`Ready: ${file.name} (${bytes.byteLength} bytes).${serialWarning}`, 'success');
} catch (error) {
+ if (selectionToken !== customFirmwareSelectionToken) return;
customFirmwareBytes = null;
this.value = '';
- flashBtn.disabled = !firmwarePathPrefix;
+ flashBtn.disabled = true;
versionNote.textContent = error.message;
versionNote.classList.remove('hidden');
setCustomFirmwareStatus(error.message, 'error');
@@ -594,6 +608,7 @@
const selected = versionSelect.options[versionSelect.selectedIndex];
if (!selected || !selected.dataset.downloadUrl || !selected.releaseMetadata) return;
+ customFirmwareSelectionToken++;
firmwarePathPrefix = selected.dataset.downloadUrl;
selectedReleaseMetadata = selected.releaseMetadata;
customFirmwareBytes = null;
@@ -650,7 +665,7 @@
}
if (publishedCount === 0) {
- if (!customFirmwareBytes) {
+ if (customFirmwareSelectionToken === 0) {
versionNote.textContent = 'No persistence-safe published firmware releases are available.';
versionNote.classList.remove('hidden');
document.getElementById('firmware-version').textContent = 'unavailable';
@@ -658,11 +673,14 @@
return;
}
- versionSelect.selectedIndex = 1;
+ versionSelect.options[0].textContent = 'Select a published release...';
versionSelect.disabled = false;
- if (!customFirmwareBytes) selectPublishedRelease();
+ if (customFirmwareSelectionToken === 0) {
+ versionSelect.selectedIndex = 1;
+ selectPublishedRelease();
+ }
} catch (e) {
- if (!customFirmwareBytes) {
+ if (customFirmwareSelectionToken === 0) {
versionNote.textContent = 'Unable to load published firmware releases.';
versionNote.classList.remove('hidden');
document.getElementById('firmware-version').textContent = 'unavailable';
diff --git a/src/main.cpp b/src/main.cpp
index aa90e94a..5eab8f9d 100644
--- a/src/main.cpp
+++ b/src/main.cpp
@@ -1055,6 +1055,13 @@ void enter_storage_recovery_mode() {
INFO("Storage recovery UI active");
}
+static void configure_loop_watchdog() {
+ esp_task_wdt_init(60, false);
+ esp_task_wdt_add(NULL);
+ RNS::Utilities::OS::set_loop_callback([]() { esp_task_wdt_reset(); });
+ INFO("Task Watchdog: loopTask subscribed (60s timeout, log-only)");
+}
+
void setup_reticulum() {
INFO("\n=== Reticulum Initialization ===");
@@ -1770,6 +1777,7 @@ void setup() {
BOOT_PROFILE_END("lvgl");
if (!persistent_storage_ready) {
+ configure_loop_watchdog();
enter_storage_recovery_mode();
return;
}
@@ -1901,9 +1909,7 @@ void setup() {
// re-adds it on the next loop iteration (CONFIG_ESP_TASK_WDT_CHECK_IDLE_TASK_CPU0=y
// is baked in and sdkconfig.defaults can't override without a framework
// rebuild from source).
- esp_task_wdt_init(60, false);
- esp_task_wdt_add(NULL); // Subscribe loopTask
- INFO("Task Watchdog: loopTask subscribed (60s timeout, log-only)");
+ configure_loop_watchdog();
// Feed WDT during long persistence + clean_cache operations (71+ entries
// to SPIFFS can take >30s). Upstream microReticulum @ 0.3.0 moved the
@@ -1911,8 +1917,6 @@ void setup() {
// callback (set via set_loop_callback), invoked during long operations
// like clean_caches, identity persistence, and the path-table flush.
// Was: Identity::set_persist_yield_callback (fork-only).
- RNS::Utilities::OS::set_loop_callback([]() { esp_task_wdt_reset(); });
-
// Show startup message
INFO("Press any key to start messaging");
}
diff --git a/tests/build_scripts/test_message_persistence_contract.py b/tests/build_scripts/test_message_persistence_contract.py
index e4c50c49..9cc3fa66 100644
--- a/tests/build_scripts/test_message_persistence_contract.py
+++ b/tests/build_scripts/test_message_persistence_contract.py
@@ -142,5 +142,7 @@ def test_failed_littlefs_mount_enters_stable_recovery_mode_before_reticulum():
assert "USB serial recovery remains available." in source
assert setup.index("if (!persistent_storage_ready)") < setup.index("setup_reticulum();")
assert "enter_storage_recovery_mode();" in setup
+ recovery_branch = setup[setup.index("if (!persistent_storage_ready)"):setup.index("return;", setup.index("if (!persistent_storage_ready)"))]
+ assert recovery_branch.index("configure_loop_watchdog();") < recovery_branch.index("enter_storage_recovery_mode();")
assert "if (storage_recovery_mode)" in loop
assert loop.index("if (storage_recovery_mode)") < loop.index("reticulum->loop();")
diff --git a/tests/build_scripts/test_web_flasher_release_safety.py b/tests/build_scripts/test_web_flasher_release_safety.py
index f8059066..b22cf090 100644
--- a/tests/build_scripts/test_web_flasher_release_safety.py
+++ b/tests/build_scripts/test_web_flasher_release_safety.py
@@ -67,6 +67,20 @@ def test_custom_firmware_upload_is_explicit_validated_and_update_only():
assert "const useFullInstall = customFirmwareBytes ? false : eraseCheckbox.checked;" in source
+def test_custom_firmware_selection_invalidates_stale_async_results():
+ source = FLASHER.read_text()
+ handler = source[source.index("customFirmwareInput.addEventListener('change'"):source.index("function selectPublishedRelease")]
+
+ assert "let customFirmwareSelectionToken = 0;" in source
+ assert "const selectionToken = ++customFirmwareSelectionToken;" in handler
+ assert handler.index("customFirmwareBytes = null;") < handler.index("await file.arrayBuffer()")
+ assert handler.index("flashBtn.disabled = true;") < handler.index("await file.arrayBuffer()")
+ assert handler.count("if (selectionToken !== customFirmwareSelectionToken) return;") >= 3
+ assert "customFirmwareSelectionToken++;" in source[source.index("function selectPublishedRelease"):]
+ assert "if (customFirmwareSelectionToken === 0) {" in source
+ assert "versionSelect.options[0].textContent = 'Select a published release...'" in source
+
+
def test_rom_connection_timeout_explains_manual_boot_sequence_and_cleans_up():
source = FLASHER.read_text()