From 28e3df02f9c22dbe990ee7d99a420b620d05823f Mon Sep 17 00:00:00 2001 From: Valentin Kivachuk Burda Date: Thu, 2 Jul 2026 08:50:20 +0200 Subject: [PATCH] ota: pause a folder pull on link loss, resume + rescan on reconnect If an `ota pull <#> folder` block-write fails mid-transfer (the motatool seeder link dropped), the fetch enters a new PAUSED state instead of failing or falling back to RAM/flash: progress stays on the host, the manager stops requesting, and loop()/stall-detection leave it untouched (it waits indefinitely). On reconnect the ESP32 seeder re-registers the folder destination and, if PAUSED, calls resumeStaged(): OP_STAT re-attaches the host's partial, the leaves are re-read, and only the missing blocks are re-requested (a brand-new/absent file restarts from 0). `ota status` reports the paused state. --- examples/companion_radio/main.cpp | 3 +++ src/helpers/ota/OtaCli.cpp | 2 ++ src/helpers/ota/OtaManager.cpp | 15 ++++++++++----- src/helpers/ota/OtaManager.h | 5 ++++- 4 files changed, 19 insertions(+), 6 deletions(-) diff --git a/examples/companion_radio/main.cpp b/examples/companion_radio/main.cpp index 3744a439..b58b65a6 100644 --- a/examples/companion_radio/main.cpp +++ b/examples/companion_radio/main.cpp @@ -145,6 +145,9 @@ void halt() { ota_seeder_attached = true; char di[24]; snprintf(di, sizeof di, "tcp %s", ota_seeder_client.remoteIP().toString().c_str()); mesh::ota::ota_ctx().set_folder_dest(&ota_folder_store, di); // offer `ota pull <#> folder` + // if a folder pull PAUSED when the link dropped, the host still holds the partial: rescan + resume + if (mesh::ota::ota_ctx().manager.fetchState() == mesh::ota::OtaManager::PAUSED) + mesh::ota::ota_ctx().manager.resumeStaged(nullptr); mesh::ota::ota_ctx().manager.announce(); // new served set -> advertise the folder's fw to peers WIFI_DEBUG_PRINTLN("OTA seeder: client connected (%s) — relay + folder pull-dest ready", di); } else { diff --git a/src/helpers/ota/OtaCli.cpp b/src/helpers/ota/OtaCli.cpp index abe91872..a8de0457 100644 --- a/src/helpers/ota/OtaCli.cpp +++ b/src/helpers/ota/OtaCli.cpp @@ -29,6 +29,7 @@ static char fstate_char(OtaManager::FetchState s) { case OtaManager::WANT_MANIFEST: return 'W'; case OtaManager::FETCHING: return 'F'; case OtaManager::COMPLETE: return 'C'; + case OtaManager::PAUSED: return 'P'; default: return 'X'; } } @@ -44,6 +45,7 @@ static const char* state_word(OtaManager::FetchState s) { case OtaManager::FETCHING: return "downloading"; case OtaManager::COMPLETE: return "ready to install"; case OtaManager::FAILED: return "failed"; + case OtaManager::PAUSED: return "paused (folder link lost — reconnect to resume)"; default: return "?"; } } diff --git a/src/helpers/ota/OtaManager.cpp b/src/helpers/ota/OtaManager.cpp index e204a057..adb7e055 100644 --- a/src/helpers/ota/OtaManager.cpp +++ b/src/helpers/ota/OtaManager.cpp @@ -446,7 +446,7 @@ void OtaManager::handleHave(const uint8_t* m, uint16_t n) { } bool OtaManager::wantRow(const uint8_t* mid, uint32_t target, uint8_t codec, uint8_t flags) const { - if (!_fetch || _fstate == FETCHING || _fstate == WANT_MANIFEST) return false; // busy with a session + if (!_fetch || _fstate == FETCHING || _fstate == WANT_MANIFEST || _fstate == PAUSED) return false; // busy if (_fstate == COMPLETE && memcmp(mid, _fid, 4) == 0) return false; // already have it if (!codecOk(codec)) return false; // can't apply this codec if (_have_desired_mid) // manual pull of a specific mid @@ -475,7 +475,7 @@ void OtaManager::armFirstReqHold() { // Begin (or resume) fetching a chosen mid: try a staged-partial resume first, else request the manifest. void OtaManager::startFetch(const uint8_t* mid, uint32_t target) { (void)target; - if (!_fetch || _fstate == FETCHING || _fstate == WANT_MANIFEST) return; + if (!_fetch || _fstate == FETCHING || _fstate == WANT_MANIFEST || _fstate == PAUSED) return; if (resumeStaged(mid)) return; // resume a partial container left in flash memcpy(_fid, mid, 4); seedBlockRng(); // per-node block-pick/jitter sequence (distinct per node) @@ -642,10 +642,15 @@ void OtaManager::handleProof(const uint8_t* m, uint16_t n) { clearReassembly(); // bad -> drop, re-fetch the block return; } - // verified -> commit the payload block, then its leaf (the present marker) - if (!_fetch->write(_fpoff + (uint32_t)_reasm_block * _fbs, _reasm_buf, blen)) return; + // verified -> commit the payload block, then its leaf (the present marker). A write failure here means a + // FOLDER destination's seeder link dropped mid-transfer: PAUSE (hold progress on the host, stop + // requesting, do NOT fall back to RAM/flash). The block is left uncommitted (its leaf stays 0xFF), so on + // reconnect resumeStaged() re-requests exactly it. (A flash store never fails these writes.) uint8_t leaf[4]; merkle_leaf(leaf, _reasm_buf, blen); - if (!_fetch->write(_floff + (uint32_t)_reasm_block * 4, leaf, 4)) return; + if (!_fetch->write(_fpoff + (uint32_t)_reasm_block * _fbs, _reasm_buf, blen) || + !_fetch->write(_floff + (uint32_t)_reasm_block * 4, leaf, 4)) { + _fstate = PAUSED; clearReassembly(); return; + } _have++; OTA_DBG("OTA: block %u OK have=%u/%u\n", (unsigned)_reasm_block, (unsigned)_have, (unsigned)_fbc); clearReassembly(); diff --git a/src/helpers/ota/OtaManager.h b/src/helpers/ota/OtaManager.h index a0c8cf04..8dfe3cbc 100644 --- a/src/helpers/ota/OtaManager.h +++ b/src/helpers/ota/OtaManager.h @@ -96,7 +96,10 @@ typedef bool (*ServeReadFn)(void* ctx, uint32_t off, uint8_t* buf, uint32_t len) class OtaManager { public: - enum FetchState : uint8_t { IDLE, WANT_MANIFEST, FETCHING, COMPLETE, FAILED }; + // PAUSED: a folder-destination write failed mid-transfer (the seeder link dropped). Progress is held on + // the host; the manager stops requesting and does NOT fall back to RAM/flash. resumeStaged() (called on + // reconnect) re-STATs the host file, recomputes which blocks are missing, and resumes. + enum FetchState : uint8_t { IDLE, WANT_MANIFEST, FETCHING, COMPLETE, FAILED, PAUSED }; // Sentinel for "no block" in the reassembly / peer-REQ / recently-served slots (a real block index is // a small uint16, so 0xFFFFFFFF is never valid).