Files
HaloKeymind/src/helpers/MQTTPrefsAtomicStore.h
T
agessaman 1be09b9bd6 fix(mqtt): harden /mqtt_prefs migration (atomic durability, tests)
Rework the /mqtt_prefs load/save path so preference migrations are
crash-safe and, for the first time, unit-testable on the host.

Most of this is extraction. The multi-format migration that previously
lived inline in CommonCLI.cpp (and could only run on-device) is moved
into three dependency-free headers so it can be exercised without
Arduino, a filesystem, or the radio stack:

  - MQTTPrefsStorage.h  frozen layout structs for every /mqtt_prefs
                        format ever shipped, with static_asserts that
                        fail the build if any on-flash offset changes.
  - MQTTPrefsCodec.h    pure format classification, field-copy
                        migration, and plausibility validation.
  - MQTTPrefsAtomicStore.h  transactional writer plus the power-cut
                        upgrade gate, both host-testable.

New behavior, beyond the refactor:

  - Atomic writes: /mqtt_prefs is written to /mqtt_prefs.tmp, verified,
    then published with an atomic rename; the writer never removes the
    existing file. A failed or interrupted save leaves the current
    config intact.
  - Power-cut ordering: LegacyUpgradeGate guarantees /mqtt_prefs is
    durably committed before the legacy /com_prefs (or /node_prefs)
    carrying the observer tail is compacted or removed, so an
    interrupted two-file upgrade retries on the next boot without
    losing settings.
  - Corrupt, unsupported-version, and newer-than-known files are
    preserved and the device boots on in-RAM defaults, rather than
    overwriting a file this firmware cannot fully decode.
  - Headerless legacy formats are validated for plausibility before
    they are trusted and rewritten (raw prefs carry no checksum).

The full historical format matrix is migrated forward to the versioned
v1 layout: pre-slot (including pre-wifi-power), 3-slot (base and
token/topic tails), and headerless 6-slot (base, audience, rx, ntp).

Scope note: only /mqtt_prefs and the one-time /node_prefs -> /com_prefs
name migration use the atomic path. Ordinary /com_prefs saves remain a
direct rewrite, unchanged by this commit.

Tests: adds two host GoogleTest suites (pio test -e native).
  - test_mqtt_prefs_codec: format classification, migration fixtures,
    v1 header integrity, downgrade preservation.
  - test_mqtt_prefs_atomic_store: transactional writes, short-write
    detection, begin/finish/rename failure cleanup, original-file
    preservation.
2026-07-18 18:44:37 -07:00

128 lines
3.4 KiB
C++

#pragma once
#include <stddef.h>
#include <stdint.h>
// Transactional writer for /mqtt_prefs. The Store interface is intentionally
// narrow so host tests can exercise every failure boundary without an Arduino
// filesystem: begin(), write(), finish(), commit(), and abort(). The caller
// supplies header and payload separately, avoiding a second full-size buffer.
namespace MQTTPrefsAtomicStore {
enum class Result : uint8_t {
Committed,
BeginFailed,
HeaderWriteFailed,
PayloadWriteFailed,
FinishFailed,
CommitFailed,
};
inline bool committed(Result result) {
return result == Result::Committed;
}
// Generic streaming transaction for structured images such as /com_prefs.
// ImageWriter writes its fields directly to Store and returns false on any
// short write, so no contiguous staging allocation is required.
enum class ImageResult : uint8_t {
Committed,
BeginFailed,
WriteFailed,
FinishFailed,
CommitFailed,
};
inline bool imageCommitted(ImageResult result) {
return result == ImageResult::Committed;
}
template <typename Store, typename ImageWriter>
inline ImageResult writeImage(Store& store, ImageWriter write_image) {
if (!store.begin()) {
store.abort();
return ImageResult::BeginFailed;
}
if (!write_image(store)) {
store.abort();
return ImageResult::WriteFailed;
}
if (!store.finish()) {
store.abort();
return ImageResult::FinishFailed;
}
if (!store.commit()) {
store.abort();
return ImageResult::CommitFailed;
}
return ImageResult::Committed;
}
// Coordinates a two-file legacy upgrade. /com_prefs must not be compacted
// until the observer tail it carries has been published into /mqtt_prefs.
// Keeping this state in a tiny pure helper lets host tests cover power-cut
// boundaries without an Arduino filesystem.
class LegacyUpgradeGate {
public:
explicit LegacyUpgradeGate(bool com_prefs_rewrite_pending)
: _com_prefs_rewrite_pending(com_prefs_rewrite_pending) {}
void requireMqttRewrite() { _mqtt_rewrite_pending = true; }
void recordMqttSave(bool did_commit) {
if (did_commit) {
_mqtt_rewrite_pending = false;
_mqtt_source_held = false;
} else {
_mqtt_source_held = true;
}
}
void holdMqttSource() { _mqtt_source_held = true; }
bool mqttRewritePending() const { return _mqtt_rewrite_pending; }
bool blocksComPrefsRewrite() const {
return _com_prefs_rewrite_pending && (_mqtt_rewrite_pending || _mqtt_source_held);
}
bool mayRewriteComPrefs() const {
return _com_prefs_rewrite_pending && !blocksComPrefsRewrite();
}
void recordComPrefsRewrite() {
if (mayRewriteComPrefs()) _com_prefs_rewrite_pending = false;
}
private:
bool _com_prefs_rewrite_pending;
bool _mqtt_rewrite_pending = false;
bool _mqtt_source_held = false;
};
template <typename Store>
inline Result write(Store& store, const uint8_t* header, size_t header_size,
const uint8_t* payload, size_t payload_size) {
if (!store.begin()) {
store.abort();
return Result::BeginFailed;
}
if (store.write(header, header_size) != header_size) {
store.abort();
return Result::HeaderWriteFailed;
}
if (store.write(payload, payload_size) != payload_size) {
store.abort();
return Result::PayloadWriteFailed;
}
if (!store.finish()) {
store.abort();
return Result::FinishFailed;
}
if (!store.commit()) {
store.abort();
return Result::CommitFailed;
}
return Result::Committed;
}
} // namespace MQTTPrefsAtomicStore