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()