Time out an abandoned admin edit transaction (#11254)
begin_edit_settings sets a bool that only the matching commit ever clears. If the commit never arrives -- the client dropped its link partway through a bulk config import, or went away entirely -- the transaction stays open indefinitely, and every later config write from any client is applied to RAM, acknowledged, and then never saved. The writes look successful and are visible in get_config, but the node reverts all of them at the next boot, and only a reboot clears it. Give the transaction a one minute idle timeout. Each deferred write restarts the clock, so the window bounds the gap between writes rather than the length of the edit; a bulk import sends them milliseconds apart. The next admin message after it lapses retires the transaction, persisting the segments it had deferred and flushing the warnings it was holding. The check runs after the auth gates and before the switch, so every case sees consistent state and the recovery's flash write happens in the main loop rather than a disconnect callback. It saves rather than rolls back because there is nothing to roll back to: each write already took effect in RAM and was acknowledged, so the transaction only ever deferred the save. That also makes an early expiry cheap -- the worst case for a client that really was still going is that its remaining writes are saved individually instead of batched. Closing on disconnect instead was tempting, but the reporter's captures show iOS dropping and reconnecting within half a second several times per session, which would abort imports that currently survive the blip. Also drops the file-scope hasOpenEditTransaction, shadowed by the class member at every use site and referenced nowhere else in the tree. Reported in #11245
This commit is contained in:
@@ -69,7 +69,6 @@
|
||||
#endif
|
||||
|
||||
AdminModule *adminModule;
|
||||
bool hasOpenEditTransaction;
|
||||
|
||||
#if !(MESHTASTIC_EXCLUDE_PKI_KEYGEN || MESHTASTIC_EXCLUDE_PKI)
|
||||
static bool licensedIdentityWillMigrate()
|
||||
@@ -240,6 +239,9 @@ bool AdminModule::handleReceivedProtobuf(const meshtastic_MeshPacket &mp, meshta
|
||||
return handled;
|
||||
}
|
||||
}
|
||||
// Before the switch, so every case below sees consistent transaction state.
|
||||
expireStaleEditTransaction();
|
||||
|
||||
switch (r->which_payload_variant) {
|
||||
|
||||
#ifdef MESHTASTIC_ENCRYPTED_STORAGE
|
||||
@@ -481,12 +483,14 @@ bool AdminModule::handleReceivedProtobuf(const meshtastic_MeshPacket &mp, meshta
|
||||
case meshtastic_AdminMessage_begin_edit_settings_tag: {
|
||||
LOG_INFO("Begin transaction for editing settings");
|
||||
hasOpenEditTransaction = true;
|
||||
editTransactionActivityMs = millis();
|
||||
break;
|
||||
}
|
||||
case meshtastic_AdminMessage_commit_edit_settings_tag: {
|
||||
disableBluetooth();
|
||||
LOG_INFO("Commit transaction for edited settings");
|
||||
hasOpenEditTransaction = false;
|
||||
deferredEditSegments = 0;
|
||||
saveChanges(SEGMENT_CONFIG | SEGMENT_MODULECONFIG | SEGMENT_DEVICESTATE | SEGMENT_CHANNELS | SEGMENT_NODEDATABASE);
|
||||
flushChannelWarnings(); // one coalesced message for everything edited in this transaction
|
||||
break;
|
||||
@@ -1875,6 +1879,23 @@ void AdminModule::reboot(int32_t seconds)
|
||||
rebootAtMsec = (seconds < 0) ? 0 : (millis() + seconds * 1000);
|
||||
}
|
||||
|
||||
// Without this, a commit that never arrives leaves the transaction open forever and every later
|
||||
// config write from any client is applied, acknowledged, and then never saved.
|
||||
void AdminModule::expireStaleEditTransaction()
|
||||
{
|
||||
if (!hasOpenEditTransaction || Throttle::isWithinTimespanMs(editTransactionActivityMs, EDIT_TRANSACTION_IDLE_MS))
|
||||
return;
|
||||
|
||||
LOG_WARN("Edit transaction abandoned for %us; committing what it applied", EDIT_TRANSACTION_IDLE_MS / 1000);
|
||||
hasOpenEditTransaction = false;
|
||||
int segments = deferredEditSegments;
|
||||
deferredEditSegments = 0;
|
||||
// No reboot: the settings are already live in RAM and the client that would expect one is gone.
|
||||
if (segments)
|
||||
saveChanges(segments, false);
|
||||
flushChannelWarnings();
|
||||
}
|
||||
|
||||
void AdminModule::saveChanges(int saveWhat, bool shouldReboot)
|
||||
{
|
||||
#ifdef PIO_UNIT_TESTING
|
||||
@@ -1885,6 +1906,8 @@ void AdminModule::saveChanges(int saveWhat, bool shouldReboot)
|
||||
service->reloadConfig(saveWhat); // Calls saveToDisk among other things
|
||||
} else {
|
||||
LOG_INFO("Delay save of changes to disk until the open transaction is committed");
|
||||
editTransactionActivityMs = millis(); // still in use, so not the abandoned kind we time out
|
||||
deferredEditSegments |= saveWhat;
|
||||
}
|
||||
if (shouldReboot && !hasOpenEditTransaction) {
|
||||
reboot(DEFAULT_REBOOT_SECONDS);
|
||||
|
||||
@@ -40,6 +40,13 @@ class AdminModule : public ProtobufModule<meshtastic_AdminMessage>, public Obser
|
||||
|
||||
private:
|
||||
bool hasOpenEditTransaction = false;
|
||||
// Each deferred write restarts the clock, so this bounds the gap between writes, not the length
|
||||
// of the edit; a bulk import sends them milliseconds apart.
|
||||
static constexpr uint32_t EDIT_TRANSACTION_IDLE_MS = 60 * 1000;
|
||||
uint32_t editTransactionActivityMs = 0; // millis() of the last save this transaction deferred
|
||||
int deferredEditSegments = 0; // segments that transaction has touched but not yet saved
|
||||
/// Retire an open edit transaction whose client stopped talking, persisting what it applied.
|
||||
void expireStaleEditTransaction();
|
||||
#ifdef PIO_UNIT_TESTING
|
||||
int lastSaveWhatForTest = 0;
|
||||
#endif
|
||||
|
||||
Reference in New Issue
Block a user