fix(companion): atomic save for contacts/channels/prefs

saveContacts/saveChannels/savePrefs truncated the live file up front and
then wrote in place, so a crash, reset or full flash mid-save wiped the
whole file — observed as all contacts disappearing after a UI hang.

Write to a temp file and only swap it in (via LittleFS atomic rename) once
every record has written cleanly; on any failure keep the previous good
file and drop the temp. savePrefs gates the swap on the tail-sentinel write
succeeding (writes return 0 once flash fills, so a good sentinel means the
whole record fit). Contacts/channels live on the multi-MB QSPIFlash, so the
transient second copy fits easily.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
MarekZegare4
2026-06-24 21:39:46 +02:00
co-authored by Claude Opus 4.8
parent 3113dabd2c
commit c917bf59fd
+96 -49
View File
@@ -44,6 +44,21 @@ static File openWrite(FILESYSTEM* fs, const char* filename) {
#endif #endif
} }
// Atomically swap a fully-written temp file over its final path. LittleFS
// (nRF52/STM32) rename replaces an existing destination atomically, so a crash
// leaves either the old file or the new one intact — never a truncated mix.
// Other Arduino filesystems can't rename onto an existing file, so the
// destination is dropped first (a metadata-only window, vs. the record-by-record
// write window of a direct overwrite). Returns false if the swap fails.
static bool commitTempFile(FILESYSTEM* fs, const char* tmp, const char* final_path) {
#if defined(NRF52_PLATFORM) || defined(STM32_PLATFORM)
return fs->rename(tmp, final_path);
#else
fs->remove(final_path);
return fs->rename(tmp, final_path);
#endif
}
#if defined(NRF52_PLATFORM) || defined(STM32_PLATFORM) #if defined(NRF52_PLATFORM) || defined(STM32_PLATFORM)
static uint32_t _ContactsChannelsTotalBlocks = 0; static uint32_t _ContactsChannelsTotalBlocks = 0;
#endif #endif
@@ -474,7 +489,9 @@ void DataStore::loadPrefsInt(const char *filename, NodePrefs& _prefs, double& no
} }
void DataStore::savePrefs(const NodePrefs& _prefs, double node_lat, double node_lon) { void DataStore::savePrefs(const NodePrefs& _prefs, double node_lat, double node_lon) {
File file = ::openWrite(_fs, "/new_prefs"); // Atomic temp-then-rename (see commitTempFile) so an interrupted save can't
// wipe settings; loadPrefs() still validates the tail sentinel on read.
File file = ::openWrite(_fs, "/new_prefs.tmp");
if (file) { if (file) {
uint8_t pad[8]; uint8_t pad[8];
memset(pad, 0, sizeof(pad)); memset(pad, 0, sizeof(pad));
@@ -598,11 +615,18 @@ void DataStore::savePrefs(const NodePrefs& _prefs, double node_lat, double node_
file.write((uint8_t *)&_prefs.locator_target_kind, sizeof(_prefs.locator_target_kind)); file.write((uint8_t *)&_prefs.locator_target_kind, sizeof(_prefs.locator_target_kind));
file.write((uint8_t *)_prefs.locator_key, sizeof(_prefs.locator_key)); file.write((uint8_t *)_prefs.locator_key, sizeof(_prefs.locator_key));
// Tail sentinel — must be last. See NodePrefs::SCHEMA_SENTINEL. // Tail sentinel — must be last. See NodePrefs::SCHEMA_SENTINEL. Its write is
// the one we check: once the flash fills, writes return 0, so a good
// sentinel write means the whole record fit. Only then swap it in.
uint32_t sentinel = NodePrefs::SCHEMA_SENTINEL; uint32_t sentinel = NodePrefs::SCHEMA_SENTINEL;
file.write((uint8_t *)&sentinel, sizeof(sentinel)); bool ok = (file.write((uint8_t *)&sentinel, sizeof(sentinel)) == sizeof(sentinel));
file.close(); file.close();
if (ok) {
commitTempFile(_fs, "/new_prefs.tmp", "/new_prefs");
} else {
_fs->remove("/new_prefs.tmp"); // keep the previous good /new_prefs
}
} }
} }
@@ -658,35 +682,48 @@ File file = openRead(_getContactsChannelsFS(), "/contacts3");
} }
void DataStore::saveContacts(DataStoreHost* host, bool (*filter)(const ContactInfo& c)) { void DataStore::saveContacts(DataStoreHost* host, bool (*filter)(const ContactInfo& c)) {
File file = ::openWrite(_getContactsChannelsFS(), "/contacts3"); FILESYSTEM* fs = _getContactsChannelsFS();
if (file) { // Write to a temp file, then atomically rename it over /contacts3 only once
uint32_t idx = 0; // every record has written cleanly. The old code truncated /contacts3 up
ContactInfo c; // front and wrote in place, so a crash, reset or full flash mid-save wiped
uint8_t unused = 0; // the entire contact list. Now an interrupted save leaves the previous good
// file untouched.
File file = ::openWrite(fs, "/contacts3.tmp");
if (!file) return;
while (host->getContactForSave(idx, c)) { bool ok = true;
if (filter && !filter(c)) { uint32_t idx = 0;
idx++; // advance to next contact ContactInfo c;
continue; uint8_t unused = 0;
}
bool success = (file.write(c.id.pub_key, 32) == 32);
success = success && (file.write((uint8_t *)&c.name, 32) == 32);
success = success && (file.write(&c.type, 1) == 1);
success = success && (file.write(&c.flags, 1) == 1);
success = success && (file.write(&unused, 1) == 1);
success = success && (file.write((uint8_t *)&c.sync_since, 4) == 4);
success = success && (file.write((uint8_t *)&c.out_path_len, 1) == 1);
success = success && (file.write((uint8_t *)&c.last_advert_timestamp, 4) == 4);
success = success && (file.write(c.out_path, 64) == 64);
success = success && (file.write((uint8_t *)&c.lastmod, 4) == 4);
success = success && (file.write((uint8_t *)&c.gps_lat, 4) == 4);
success = success && (file.write((uint8_t *)&c.gps_lon, 4) == 4);
if (!success) break; // write failed
while (host->getContactForSave(idx, c)) {
if (filter && !filter(c)) {
idx++; // advance to next contact idx++; // advance to next contact
continue;
} }
file.close(); bool success = (file.write(c.id.pub_key, 32) == 32);
success = success && (file.write((uint8_t *)&c.name, 32) == 32);
success = success && (file.write(&c.type, 1) == 1);
success = success && (file.write(&c.flags, 1) == 1);
success = success && (file.write(&unused, 1) == 1);
success = success && (file.write((uint8_t *)&c.sync_since, 4) == 4);
success = success && (file.write((uint8_t *)&c.out_path_len, 1) == 1);
success = success && (file.write((uint8_t *)&c.last_advert_timestamp, 4) == 4);
success = success && (file.write(c.out_path, 64) == 64);
success = success && (file.write((uint8_t *)&c.lastmod, 4) == 4);
success = success && (file.write((uint8_t *)&c.gps_lat, 4) == 4);
success = success && (file.write((uint8_t *)&c.gps_lon, 4) == 4);
if (!success) { ok = false; break; } // write failed (e.g. flash full)
idx++; // advance to next contact
}
file.close();
if (ok) {
commitTempFile(fs, "/contacts3.tmp", "/contacts3");
} else {
fs->remove("/contacts3.tmp"); // keep the previous good /contacts3
} }
} }
@@ -738,29 +775,39 @@ void DataStore::loadChannels(DataStoreHost* host) {
} }
void DataStore::saveChannels(DataStoreHost* host) { void DataStore::saveChannels(DataStoreHost* host) {
File file = ::openWrite(_getContactsChannelsFS(), "/channels2"); FILESYSTEM* fs = _getContactsChannelsFS();
if (file) { // Same atomic temp-then-rename pattern as saveContacts() — never truncate the
uint8_t channel_idx = 0; // live /channels2 before the new copy is fully written.
ChannelDetails ch; File file = ::openWrite(fs, "/channels2.tmp");
uint8_t unused[4]; if (!file) return;
memset(unused, 0, 4);
while (host->getChannelForSave(channel_idx, ch)) { bool ok = true;
channel_idx++; uint8_t channel_idx = 0;
// getChannelForSave() returns every slot up to MAX_GROUP_CHANNELS, so skip ChannelDetails ch;
// the unused ones (all-zero secret) rather than writing all 40 — otherwise uint8_t unused[4];
// the file is always ~2.7 KB and wears the flash needlessly. loadChannels() memset(unused, 0, 4);
// already compacts empty entries on read, so the loaded result is identical.
bool empty = true;
for (int b = 0; b < 32; b++) if (ch.channel.secret[b]) { empty = false; break; }
if (empty) continue;
bool success = (file.write(unused, 4) == 4); while (host->getChannelForSave(channel_idx, ch)) {
success = success && (file.write((uint8_t *)ch.name, 32) == 32); channel_idx++;
success = success && (file.write((uint8_t *)ch.channel.secret, 32) == 32); // getChannelForSave() returns every slot up to MAX_GROUP_CHANNELS, so skip
if (!success) break; // write failed // the unused ones (all-zero secret) rather than writing all 40 — otherwise
} // the file is always ~2.7 KB and wears the flash needlessly. loadChannels()
file.close(); // already compacts empty entries on read, so the loaded result is identical.
bool empty = true;
for (int b = 0; b < 32; b++) if (ch.channel.secret[b]) { empty = false; break; }
if (empty) continue;
bool success = (file.write(unused, 4) == 4);
success = success && (file.write((uint8_t *)ch.name, 32) == 32);
success = success && (file.write((uint8_t *)ch.channel.secret, 32) == 32);
if (!success) { ok = false; break; } // write failed
}
file.close();
if (ok) {
commitTempFile(fs, "/channels2.tmp", "/channels2");
} else {
fs->remove("/channels2.tmp"); // keep the previous good /channels2
} }
} }