diff --git a/zephcore/adapters/datastore/ZephyrDataStore.cpp b/zephcore/adapters/datastore/ZephyrDataStore.cpp index 9d8f1bc..f669f85 100644 --- a/zephcore/adapters/datastore/ZephyrDataStore.cpp +++ b/zephcore/adapters/datastore/ZephyrDataStore.cpp @@ -1084,12 +1084,27 @@ void ZephyrDataStore::loadContacts(DataStoreHost *host) void ZephyrDataStore::saveContacts(DataStoreHost *host) { const char *path = contactsFile(); + + /* Atomic replace ONLY where there is external flash — deliberately, and + * not to be "fixed" later. + * + * atomicWriteTempFile() needs room for a second full copy before the + * rename. contacts3 is by far the largest store here (152 B per record, + * ~47 KB at 313 contacts) and internal /lfs on these boards is 128 KB + * total, shared with identity, prefs and channels2. Two copies would sit + * at ~94 KB of 128 KB before LittleFS metadata, so the atomic path could + * fail with ENOSPC exactly when it is most needed — a worse failure than + * the one it prevents. + * + * channels2, identity and prefs are atomic everywhere because they are + * small enough for the second copy to be free. Only contacts is gated. + * + * The non-atomic branch below is therefore the constrained-board path, + * and it writes in place and truncates rather than unlinking first — see + * the note there. */ bool use_atomic = _has_ext_fs; const char *save_mode = use_atomic ? "atomic" : "direct"; - if (!use_atomic && exists(path)) { - fs_unlink(path); - } struct fs_file_t file; uint8_t rec[CONTACT_DATA_SZ]; @@ -1126,6 +1141,21 @@ void ZephyrDataStore::saveContacts(DataStoreHost *host) }; write_ok = atomicWriteTempFile(path, atomic_contacts_writer, &ctx, "saveContacts"); } else { + /* Overwrite in place, then truncate — never unlink first. + * + * This branch runs on boards with no external flash, i.e. the ones + * that cannot afford the atomic temp-file dance. It used to + * fs_unlink() the contacts file before recreating it, which left a + * window spanning the whole ~47 KB write where contacts3 did not + * exist at all: a power cut there lost every contact rather than + * corrupting some. The unlink was only ever a way to truncate. + * + * Truncating afterwards is the same guarantee without the window — + * the file is always present, and at worst briefly longer than its + * new contents (stale records past the end, which the truncate then + * removes). FS_O_TRUNC is NOT usable here: Zephyr's LittleFS + * backend maps only CREATE/READ/WRITE/APPEND and drops TRUNC + * silently, so asking for it would leave the stale tail in place. */ fs_file_t_init(&file); int rc = fs_open(&file, path, FS_O_CREATE | FS_O_WRITE); if (rc < 0) { @@ -1133,6 +1163,20 @@ void ZephyrDataStore::saveContacts(DataStoreHost *host) return; } write_ok = write_contacts(&file); + + int trunc_rc = 0; + + if (write_ok) { + trunc_rc = fs_truncate(&file, + (off_t)written * CONTACT_DATA_SZ); + if (trunc_rc < 0) { + LOG_ERR("saveContacts: truncate to %u failed: %d", + (unsigned)(written * CONTACT_DATA_SZ), + trunc_rc); + write_ok = false; + } + } + int sync_rc = fs_sync(&file); fs_close(&file); if (!write_ok || sync_rc < 0) { diff --git a/zephcore/app/CompanionMesh.cpp b/zephcore/app/CompanionMesh.cpp index fea453a..105b3db 100644 --- a/zephcore/app/CompanionMesh.cpp +++ b/zephcore/app/CompanionMesh.cpp @@ -387,12 +387,24 @@ void CompanionMesh::onChannelAdded(ChannelDetails *) markChannelsDirty(); } -void CompanionMesh::markContactsDirty() +void CompanionMesh::markContactsDirty(bool substantive) { + int64_t deadline = _ms->getMillis() + + (substantive ? LAZY_WRITE_DELAY_MS + : LAZY_WRITE_LIVENESS_MS); + /* Only set the timer on first dirty — don't keep pushing * the deadline forward or a busy mesh never flushes. */ if (!_dirty_contacts_expiry) { - _dirty_contacts_expiry = _ms->getMillis() + LAZY_WRITE_DELAY_MS; + _dirty_contacts_expiry = deadline; + return; + } + + /* ...but a substantive change must not have to sit out a liveness wait + * that is already pending. Pulling the deadline IN cannot starve the + * flush, only hasten it. */ + if (substantive && deadline < _dirty_contacts_expiry) { + _dirty_contacts_expiry = deadline; } } @@ -727,8 +739,13 @@ void CompanionMesh::onDiscoveredContact(ContactInfo &contact, bool is_new, uint8 LOG_INF("onDiscoveredContact: '%s' is_new=%d path_len=%d num_contacts=%d", contact.name, is_new, path_len, getNumContacts()); - // Mark contacts dirty for lazy save - markContactsDirty(); + /* A re-heard advert from a contact we already have is liveness only — + * the base class has just refreshed last_advert_timestamp and lastmod, + * and nothing else typically moved. Persist it lazily rather than + * spending a full-file rewrite per advert; a genuinely new contact, a + * path change (onContactPathUpdated) or a message all still flush on the + * short deadline. */ + markContactsDirty(is_new); // Update advert path table if (path && mesh::Packet::isValidPathLen(path_len)) { diff --git a/zephcore/app/CompanionMesh.h b/zephcore/app/CompanionMesh.h index bf802e1..a1f90cb 100644 --- a/zephcore/app/CompanionMesh.h +++ b/zephcore/app/CompanionMesh.h @@ -417,9 +417,25 @@ private: int64_t _dirty_channels_expiry; static constexpr int64_t LAZY_WRITE_DELAY_MS = 5000; /* 5 seconds, matches Arduino */ + /* Deadline for liveness-only contact updates — a re-heard advert from a + * contact we already know, where the only fields that moved are + * last_advert_timestamp and lastmod. + * + * saveContacts() rewrites the WHOLE file (fixed 152-byte records, no + * incremental path), which on a board without external flash is ~47 KB + * into a 128 KB LittleFS partition. Measured on a T1000-E 2026-08-24: + * 21 full rewrites in 90 minutes, one per advert arrival, because every + * re-advert marked the file dirty on the same 5 s deadline as a real + * change. That is a flash-wear problem on a battery tracker. + * + * Liveness still persists — it just waits, so an hour of re-adverts + * costs one write instead of fourteen. A substantive change (new + * contact, message, path update) still pulls the deadline back in. */ + static constexpr int64_t LAZY_WRITE_LIVENESS_MS = 600000; /* 10 minutes */ + void onLoginSent(const ContactInfo &contact) override; void onChannelAdded(ChannelDetails *ch) override; - void markContactsDirty(); + void markContactsDirty(bool substantive = true); void markChannelsDirty(); void flushDirtyContacts(); void flushDirtyChannels();