fix(mqtt): undo a failed /mqtt.json publish instead of claiming a rollback

Publishing moves the old primary to .bak before the verified temp takes
that name, so a failed second rename left the new image exactly where
boot recovery promotes it — while the observer setter told the operator
the change had been rolled back. The refused value came back at the next
reset.

Restore the backup and discard the temp on that path, and distinguish
CommitIndeterminate from CommitFailed when the filesystem cannot be put
back, so the CLI reply says the flash state is unresolved rather than
claiming the change is gone. The indeterminate condition latches for the
boot: the artifact left behind also makes every later transaction fail to
begin, so it cannot clear itself.

Also state the version-first rule the future-version probe depends on.
The probe reads the root version with this firmware's grammar, so a newer
file that introduces unknown syntax ahead of that field reads as corrupt
rather than future and loses its preservation guarantee.

Tests: publish-failure rollback and the indeterminate outcome against a
SPIFFS-shaped store fake; the version-first writer invariant and the cost
of violating it; /prefs.json coverage for the strict shape checks
(deployed-shape file, unknown nested groups, torn files, mismatches).
This commit is contained in:
agessaman committed 2026-08-15 15:09:55 -07:00
1 parent 71847b3c92
commit dec4a995ea
9 files changed
+469 -49

No files matched your search

+54 -12
View File
@@ -608,12 +608,14 @@ public:
_finished = false;
_open = false;
_owns_temp = false;
_owns_backup = false;
_bytes_written = 0;
_expected_crc = MQTT_JSON_FNV1A_OFFSET_BASIS;
// Recovery owns stale artifacts. Do not delete them here: a failed commit
// may have moved the old primary to .bak and left a verified temp that the
// next boot must choose between. Refusing the save is safer than erasing an
// image this firmware cannot decode.
// Recovery owns stale artifacts. Do not delete them here: a power cut, or a
// failed commit that could not be rolled back, may have moved the old
// primary to .bak and left a verified temp that the next boot must choose
// between. Refusing the save is safer than erasing an image this firmware
// cannot decode.
if (_fs->exists("/mqtt.json.tmp") || _fs->exists("/mqtt.json.bak")) return false;
#if defined(NRF52_PLATFORM) || defined(STM32_PLATFORM)
_file = _fs->open("/mqtt.json.tmp", FILE_O_WRITE);
@@ -679,11 +681,12 @@ public:
if (!_finished) return false;
// SPIFFS refuses rename(tmp, existing_dest). Move the existing image to a
// recoverable backup first, then publish temp into the now-empty primary.
// Never remove either image after a failed boundary; boot recovery selects
// the completed temp or restores the backup.
// A power cut at either boundary is resolved by boot recovery; a failure
// that returns here is undone by rollbackFailedCommit().
if (_fs->exists("/mqtt.json.bak")) return false;
if (_fs->exists("/mqtt.json") && !_fs->rename("/mqtt.json", "/mqtt.json.bak")) {
return false;
if (_fs->exists("/mqtt.json")) {
if (!_fs->rename("/mqtt.json", "/mqtt.json.bak")) return false;
_owns_backup = true;
}
if (!_fs->rename("/mqtt.json.tmp", "/mqtt.json")) return false;
// Cleanup failure is non-fatal: the new primary is published and recovery
@@ -692,6 +695,32 @@ public:
return true;
}
// Undo a commit that failed midway. Without this, a failed publish can leave
// the old primary parked in .bak and the verified temp still holding the new
// image, which boot recovery then promotes — so a setter that told the
// operator the change was rolled back would be wrong after the next reset.
//
// Republishing the backup is the operation that matters: once the primary
// name is occupied, recovery keeps it and treats the temp as stale, so the
// temp removal below is only housekeeping. Returns false when the
// pre-transaction state could not be restored and the outcome of the next
// boot is therefore uncertain.
bool rollbackFailedCommit() {
// Only ever restore the backup this transaction made. Anything else under
// that name predates the transaction and is recovery's to resolve.
if (_owns_backup && !_fs->exists("/mqtt.json") &&
!_fs->rename("/mqtt.json.bak", "/mqtt.json")) {
return false;
}
_owns_backup = false;
if (_owns_temp && _fs->exists("/mqtt.json.tmp") && !_fs->remove("/mqtt.json.tmp")) {
// A verified temp still outranks a missing primary during recovery, so
// this is only harmless if the primary name is occupied again.
return _fs->exists("/mqtt.json");
}
return true;
}
void discardFinishedTemp() {
if (_open) _file.close();
_open = false;
@@ -703,14 +732,15 @@ public:
void abort() {
if (_open) _file.close();
_open = false;
// Once finish() has verified the temp, commit may already have moved the
// primary to .bak. Keep the temp on a commit failure so recovery can
// publish it (or fall back to .bak) after reset.
// Only unfinished staging is disposable here. Once finish() has verified
// the temp, rollbackFailedCommit() has already decided its fate, and a temp
// that survived that is one recovery must resolve after reset.
if (_owns_temp && !_finished && _fs->exists("/mqtt.json.tmp")) {
_fs->remove("/mqtt.json.tmp");
}
_finished = false;
_owns_temp = false;
_owns_backup = false;
}
private:
@@ -719,6 +749,9 @@ private:
bool _open = false;
bool _finished = false;
bool _owns_temp = false;
// This transaction moved the old primary to /mqtt.json.bak, so a failed
// publish may put it back. Never true for a backup it did not create.
bool _owns_backup = false;
size_t _bytes_written = 0;
uint32_t _expected_crc = MQTT_JSON_FNV1A_OFFSET_BASIS;
};
@@ -1061,7 +1094,16 @@ bool CommonCLI::saveMQTTPrefs(FILESYSTEM* fs) {
}
break;
case MQTTPrefsAtomicStore::VerifiedImageResult::CommitFailed:
MESH_DEBUG_PRINTLN("MQTT: atomic /mqtt.json save failed during rename; recovery files preserved");
MESH_DEBUG_PRINTLN("MQTT: atomic /mqtt.json save failed during rename; transaction rolled back");
break;
case MQTTPrefsAtomicStore::VerifiedImageResult::CommitIndeterminate:
// The failed publish could not be undone, so a recovery file this
// firmware may still promote is holding the new image. No further save
// can start until it is resolved: begin() refuses while either the temp
// or the backup exists, which is exactly the state that gets us here.
_observer_save_indeterminate = true;
MESH_DEBUG_PRINTLN("MQTT: atomic /mqtt.json save failed during rename and could not be rolled back; "
"recovery files preserved");
break;
}
return false;
+5
View File
@@ -373,6 +373,11 @@ class CommonCLI {
// /mqtt.json is newer, corrupt, or temporarily unreadable. The in-memory prefs
// run on defaults and saveMQTTPrefs() must not overwrite the source file.
bool _mqtt_prefs_hold = false;
// A failed publish could not be undone, so the next boot may still come up
// with the value that was refused. persistObserverPrefs() must not call that
// a rollback. Latched for the boot: the artifact left behind also makes every
// later transaction fail to begin, so the condition cannot clear itself.
bool _observer_save_indeterminate = false;
#endif
bool _com_prefs_needs_upgrade = false; // old-format legacy prefs detected; rewrite once after load
+7 -1
View File
@@ -208,7 +208,13 @@ bool CommonCLI::persistObserverPrefs(char* reply) {
if (_observer_prefs_rollback != nullptr) {
memcpy(&_mqtt_prefs, _observer_prefs_rollback, sizeof(_mqtt_prefs));
}
strcpy(reply, "Error: setting not persisted; change rolled back");
// The running node is back on the old value either way, but only claim the
// change is gone when the write really was undone on flash.
if (_observer_save_indeterminate) {
strcpy(reply, "Error: setting not persisted; flash state unresolved, recheck after reboot");
} else {
strcpy(reply, "Error: setting not persisted; change rolled back");
}
return false;
#else
(void)reply;
+15 -2
View File
@@ -11,7 +11,14 @@ namespace MQTTPrefsAtomicStore {
// Production /mqtt.json flow. finish() verifies the exact bytes written,
// verify_image reparses the finished temp through the schema, and only then is
// commit() allowed to publish it. A schema-verification failure discards the
// finished temp; a commit failure preserves it for boot recovery.
// finished temp; a commit failure is undone by rollbackFailedCommit().
//
// CommitFailed and CommitIndeterminate are distinct outcomes, not shades of the
// same one. Publishing moves the old primary aside before the verified temp
// takes its name, so a half-done commit leaves an image that boot recovery
// would promote. CommitFailed means that was undone and the change is really
// gone; CommitIndeterminate means it could not be, and the next boot may still
// come up with the new value. Callers must not report the two the same way.
enum class VerifiedImageResult : uint8_t {
Committed,
BeginFailed,
@@ -19,6 +26,7 @@ enum class VerifiedImageResult : uint8_t {
FinishFailed,
VerifyFailed,
CommitFailed,
CommitIndeterminate,
};
template <typename Store, typename ImageWriter, typename ImageVerifier>
@@ -42,8 +50,13 @@ inline VerifiedImageResult writeVerifiedImage(Store& store,
return VerifiedImageResult::VerifyFailed;
}
if (!store.commit()) {
// Undo the partial publish before answering. Only the store knows whether
// the pre-transaction state was actually restored, so take its word for
// which failure this was.
const bool rolled_back = store.rollbackFailedCommit();
store.abort();
return VerifiedImageResult::CommitFailed;
return rolled_back ? VerifiedImageResult::CommitFailed
: VerifiedImageResult::CommitIndeterminate;
}
return VerifiedImageResult::Committed;
}
+10 -1
View File
@@ -9,6 +9,15 @@
static const int32_t MQTT_PREFS_JSON_FORMAT_VERSION = 1;
// Format invariant, binding on every future schema version: `version` is the
// FIRST property of the root object. The probe below reads it with this
// firmware's grammar and stops at the first construct that grammar cannot
// tokenize, so a newer file that introduces unknown syntax ahead of its version
// field is indistinguishable from a corrupt one here. That costs it the
// opaque-future preservation guarantee: as a temp left by an interrupted commit
// it would be classified as discardable rather than retained. Keep the version
// first when adding fields or bumping the version; a host test pins it.
//
// Reads only the mandatory root version while ignoring every other schema
// field. Recovery uses this before the v1 decoder so a syntactically valid
// future file remains opaque even when that schema changes a v1 field's type.
@@ -369,7 +378,7 @@ class MQTTPrefsSerializer : public ConfigSerializer {
protected:
void structure() override {
defStrict("version", _version, _seen_version);
defStrict("version", _version, _seen_version); // must stay first; see above
def("wifi", _wifi);
def("time", _time);
def("mqtt", _mqtt);