Updates: a download that breaks is carried on
CI / build (pull_request) Successful in 2m6s
Site / build (pull_request) Successful in 11s

When the connection breaks during a release download (issue #54), the
device waits up to a minute for Wi-Fi, asks for the rest of the file
with a Range request and carries on: the slot and the hash so far are
kept. It gives up after three tries in a row that bring nothing.
Nothing is kept across a restart.

Carrying on asks for 64 KB free, not 80: between two connections of an
install 78 KB is free, and the first attempt on the device was refused
for that. update damage cut now breaks every connection after n bytes.

Tried on the device: v0.23.0 installed over four connections, and a
download that brought nothing given up with nothing switched.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0162FokPdvY2KsS4NBfwyWPk
This commit is contained in:
2026-10-11 18:26:39 +02:00
co-authored by Claude Opus 5.5
parent 468dc0dc0d
commit 472f13260f
12 changed files with 155 additions and 21 deletions
+3 -1
View File
@@ -579,6 +579,7 @@ static void updateCommand(const String& args) {
console.printf("update: installing by itself %s%s%s\n", settings.getBool(Setting::InstallUpdates) ? "on" : "off",
autoUpdate.waiting() ? ", waiting for a quiet moment to offer " : autoUpdate.asking() ? ", asking about " : "",
autoUpdate.waiting() || autoUpdate.asking() ? autoUpdate.tag().c_str() : "");
console.printf("update: the last download was carried on %d time(s) after a break\n", update->lastResumes());
console.printf("update: gitea %s %s\n", g.status() == GiteaReleases::Status::Done ? "done" : g.status() == GiteaReleases::Status::Failed ? "failed" : g.status() == GiteaReleases::Status::Busy ? "busy" : "never asked", g.error().c_str());
printReleases(false);
} else if (args.startsWith("install ")) {
@@ -594,7 +595,8 @@ static void updateCommand(const String& args) {
} else if (args.startsWith("damage ")) { // update damage cut <bytes> | flip <offset>: for the next download
long n = args.substring(args.lastIndexOf(' ') + 1).toInt();
args.startsWith("damage cut") ? update->damageNextDownload(n, -1) : update->damageNextDownload(-1, n);
console.printf("update: the next download will be %s at byte %ld\n", args.startsWith("damage cut") ? "cut" : "damaged", n);
if (args.startsWith("damage cut")) console.printf("update: each connection of the next download will break after %ld bytes\n", n);
else console.printf("update: the next download will be damaged at byte %ld\n", n);
} else if (args == "daily") { // forget today's check: the daily one runs again at once, if it may
update->forgetToday();
+13 -3
View File
@@ -27,9 +27,20 @@ std::string whyNotConnected(NetworkClientSecure& tls, const std::string& host) {
} // namespace
std::string HttpsGet::open(const std::string& host, const std::string& path, const char* accept) {
std::string why = request(host, path, accept, "", kNeedFree);
if (!why.empty()) return why;
if (head_.status != 200) return host + " answered " + std::to_string(head_.status) + (head_.status / 100 == 3 ? " (a redirect)" : "");
return "";
}
std::string HttpsGet::openFrom(const std::string& host, const std::string& path, const char* accept, long from, const std::string& ifRange) {
return request(host, path, accept, "Range: bytes=" + std::to_string(from) + "-\r\n" + (ifRange.empty() ? "" : "If-Range: " + ifRange + "\r\n"), kNeedFreeToCarryOn);
}
std::string HttpsGet::request(const std::string& host, const std::string& path, const char* accept, const std::string& more, size_t needFree) {
close();
if (time(nullptr) < kClockSetAfter) return "The clock isn't set: can't check certificates";
if (esp_get_free_heap_size() < kNeedFree) return "Not enough memory for a secure connection";
if (esp_get_free_heap_size() < needFree) return "Not enough memory for a secure connection";
tls_.setCACert(kTrustedRootsPem);
tls_.setTimeout(15);
@@ -38,7 +49,7 @@ std::string HttpsGet::open(const std::string& host, const std::string& path, con
raw_.reset(new (std::nothrow) uint8_t[kRaw]);
if (!raw_) return "Not enough memory";
tls_.print(("GET " + path + " HTTP/1.1\r\nHost: " + host + "\r\nUser-Agent: roro9stack/" + versionString() +
"\r\nAccept: " + accept + "\r\nAccept-Encoding: identity\r\nConnection: close\r\n\r\n")
"\r\nAccept: " + accept + "\r\nAccept-Encoding: identity\r\n" + more + "Connection: close\r\n\r\n")
.c_str());
release::HttpHeadParser parser;
@@ -50,7 +61,6 @@ std::string HttpsGet::open(const std::string& host, const std::string& path, con
if (parser.complete() && used < static_cast<size_t>(n)) early_.assign(reinterpret_cast<const char*>(raw_.get()) + used, n - used);
}
head_ = parser.head();
if (head_.status != 200) return host + " answered " + std::to_string(head_.status) + (head_.status / 100 == 3 ? " (a redirect)" : "");
left_ = head_.chunked ? -1 : head_.contentLength;
return "";
}
+11
View File
@@ -20,12 +20,22 @@ class HttpsGet {
// checking the certificate); with Q86's 20 KB to spare, it starts with at least this much free.
static constexpr size_t kNeedFree = 80 * 1024;
// Carrying on a download (issue #54) happens in the middle of an install, whose own buffers
// (about 8 KB, measured: 78 KB free between two connections) are already taken out of what is
// free, and with the screen taken over nothing else will ask for memory: the peak and 12 KB.
static constexpr size_t kNeedFreeToCarryOn = 64 * 1024;
explicit HttpsGet(net::User user) : tls_(user) {}
~HttpsGet() { close(); }
// Connects and reads the head. "" when a 200 is on its way, or why not, in words for the screen.
std::string open(const std::string& host, const std::string& path, const char* accept);
// The same, for the rest of a file from byte `from` (issue #54): asks for that range, with
// `ifRange` (an ETag, or "") so that a file that changed comes whole. The answer, a 206 or
// not, is for the caller to judge with head(): "" here only says a head was read.
std::string openFrom(const std::string& host, const std::string& path, const char* accept, long from, const std::string& ifRange);
long contentLength() const { return head_.contentLength; } // -1: not known
const release::HttpHead& head() const { return head_; }
// Body bytes into `buf`: how many, 0 at the end, -1 when the connection broke or stalled
// before the end.
@@ -37,6 +47,7 @@ class HttpsGet {
static constexpr uint32_t kStallMs = 10000;
int readRaw(uint8_t* into, size_t len); // from the connection, waiting up to kStallMs
std::string request(const std::string& host, const std::string& path, const char* accept, const std::string& more, size_t needFree);
Counted<NetworkClientSecure> tls_;
release::HttpHead head_;
+53 -9
View File
@@ -10,6 +10,7 @@
#include <ctime>
#include "http_head.h"
#include "resume.h"
#include "platform/https_get.h"
#include "platform/ota_device.h"
#include "platform/system_info.h"
@@ -64,21 +65,61 @@ class FileSource : public UpdateSource {
File& f_;
};
// A release being downloaded from Gitea: the same bytes a push or a card would give.
// A release being downloaded from Gitea: the same bytes a push or a card would give. When the
// connection breaks, it is opened again for the rest of the file (issue #54): what was written
// to the slot stays, and the parser never sees the break.
class GiteaSource : public UpdateSource {
public:
GiteaSource(HttpsGet& get, long cutAfter, long flipAt) : get_(get), cut_(cutAfter), flip_(flipAt) {}
static constexpr uint32_t kWifiWaitMs = 60000; // for Wi-Fi to come back, before each try
static constexpr uint32_t kPauseMs = 2000;
GiteaSource(HttpsGet& get, const release::Url& url, WifiService& wifi, long cutAfter, long flipAt)
: get_(get), url_(url), wifi_(wifi), total_(get.contentLength()), etag_(get.head().etag), cut_(cutAfter), flip_(flipAt) {}
int read(uint8_t* buf, size_t len) override {
if (cut_ >= 0 && pos_ >= cut_) return -1; // Debug Builds: the connection "breaks" here
int n = get_.read(buf, len);
if (n > 0 && flip_ >= pos_ && flip_ < pos_ + n) buf[flip_ - pos_] ^= 1; // and a byte "arrives wrong"
if (n > 0) pos_ += n;
return n;
for (;;) {
// Debug: each connection "breaks" after cut_ bytes.
int n = cut_ >= 0 && pos_ - openedAt_ >= cut_ ? -1 : get_.read(buf, len);
if (n > 0 && flip_ >= pos_ && flip_ < pos_ + n) buf[flip_ - pos_] ^= 1; // and a byte "arrives wrong"
if (n > 0) pos_ += n;
if (n >= 0) return n;
if (!resume()) return -1;
}
}
int resumes() const { return policy_.resumes(); }
const std::string& why() const { return why_; } // why it gave up, when it did
private:
bool resume() {
// Without a length there is no knowing what "the rest" is.
while (total_ > 0 && pos_ < total_ && policy_.again(pos_)) {
get_.close();
delay(kPauseMs);
uint32_t since = millis();
while (wifi_.state() != WifiController::State::Connected && millis() - since < kWifiWaitMs) delay(250);
if (wifi_.state() != WifiController::State::Connected) {
why_ = "Wi-Fi did not come back";
continue;
}
why_ = get_.openFrom(url_.host, url_.path, "application/octet-stream", pos_, release::ifRangeFor(etag_));
if (!why_.empty()) continue; // no connection yet: another try, while the policy allows
why_ = release::resumeRefusal(get_.head(), pos_, total_, etag_);
if (!why_.empty()) return false; // it answered, and it is not the rest of this file
openedAt_ = pos_;
ESP_LOGW("update", "carrying on from byte %ld of %ld (try %d)", pos_, total_, policy_.resumes());
return true;
}
if (why_.empty()) why_ = "The download broke";
return false;
}
HttpsGet& get_;
long cut_, flip_, pos_ = 0;
release::Url url_;
WifiService& wifi_;
long total_;
std::string etag_, why_;
release::ResumePolicy policy_;
long cut_, flip_, pos_ = 0, openedAt_ = 0;
};
constexpr time_t kClockSetAfter = 1700000000; // anything earlier is the clock's default
@@ -363,9 +404,12 @@ void UpdateService::installFromGitea(const release::Release& r) {
notify("Update refused: " + why, NotificationLevel::Warning);
return;
}
GiteaSource source(get, damageCut_, damageFlip_);
GiteaSource source(get, url, wifi_, damageCut_, damageFlip_);
damageCut_ = damageFlip_ = -1;
install(source, "Gitea");
resumes_ = source.resumes();
// What the parser says after a break is only that the file was short: say why it broke.
if (phase_ != Phase::Installed && !source.why().empty()) notify("Download gave up: " + source.why(), NotificationLevel::Warning);
}
void UpdateService::taskEntry(void* self) { static_cast<UpdateService*>(self)->listen(); }
+2
View File
@@ -71,6 +71,7 @@ class UpdateService : public Service {
// (is its certificate accepted?), and a download cut short or with one byte flipped.
void requestProbe(const std::string& host, const std::string& path) { probeHost_ = host; probePath_ = path; probeResult_ = "..."; request_ = Request::Probe; }
std::string probeResult() const { return probeResult_; }
int lastResumes() const { return resumes_; } // how often the last download from Gitea was carried on (issue #54)
void damageNextDownload(long cutAfter, long flipAt) { damageCut_ = cutAfter; damageFlip_ = flipAt; }
void forgetToday() { store_.putInt("rel_day", 0); nextCheckMs_ = 0; announced_.clear(); } // the daily check runs at the next tick
@@ -115,6 +116,7 @@ class UpdateService : public Service {
uint32_t nextCheckMs_ = 90000; // not before the device has settled
std::string probeHost_, probePath_;
volatile long damageCut_ = -1, damageFlip_ = -1;
volatile int resumes_ = 0;
std::string probeResult_;
};