Skip to content

Commit ae16c05

Browse files
authored
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
1 parent f61f658 commit ae16c05

4 files changed

Lines changed: 101 additions & 2 deletions

File tree

src/modules/AdminModule.cpp

Lines changed: 24 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -69,7 +69,6 @@
6969
#endif
7070

7171
AdminModule *adminModule;
72-
bool hasOpenEditTransaction;
7372

7473
#if !(MESHTASTIC_EXCLUDE_PKI_KEYGEN || MESHTASTIC_EXCLUDE_PKI)
7574
static bool licensedIdentityWillMigrate()
@@ -240,6 +239,9 @@ bool AdminModule::handleReceivedProtobuf(const meshtastic_MeshPacket &mp, meshta
240239
return handled;
241240
}
242241
}
242+
// Before the switch, so every case below sees consistent transaction state.
243+
expireStaleEditTransaction();
244+
243245
switch (r->which_payload_variant) {
244246

245247
#ifdef MESHTASTIC_ENCRYPTED_STORAGE
@@ -481,12 +483,14 @@ bool AdminModule::handleReceivedProtobuf(const meshtastic_MeshPacket &mp, meshta
481483
case meshtastic_AdminMessage_begin_edit_settings_tag: {
482484
LOG_INFO("Begin transaction for editing settings");
483485
hasOpenEditTransaction = true;
486+
editTransactionActivityMs = millis();
484487
break;
485488
}
486489
case meshtastic_AdminMessage_commit_edit_settings_tag: {
487490
disableBluetooth();
488491
LOG_INFO("Commit transaction for edited settings");
489492
hasOpenEditTransaction = false;
493+
deferredEditSegments = 0;
490494
saveChanges(SEGMENT_CONFIG | SEGMENT_MODULECONFIG | SEGMENT_DEVICESTATE | SEGMENT_CHANNELS | SEGMENT_NODEDATABASE);
491495
flushChannelWarnings(); // one coalesced message for everything edited in this transaction
492496
break;
@@ -1875,6 +1879,23 @@ void AdminModule::reboot(int32_t seconds)
18751879
rebootAtMsec = (seconds < 0) ? 0 : (millis() + seconds * 1000);
18761880
}
18771881

1882+
// Without this, a commit that never arrives leaves the transaction open forever and every later
1883+
// config write from any client is applied, acknowledged, and then never saved.
1884+
void AdminModule::expireStaleEditTransaction()
1885+
{
1886+
if (!hasOpenEditTransaction || Throttle::isWithinTimespanMs(editTransactionActivityMs, EDIT_TRANSACTION_IDLE_MS))
1887+
return;
1888+
1889+
LOG_WARN("Edit transaction abandoned for %us; committing what it applied", EDIT_TRANSACTION_IDLE_MS / 1000);
1890+
hasOpenEditTransaction = false;
1891+
int segments = deferredEditSegments;
1892+
deferredEditSegments = 0;
1893+
// No reboot: the settings are already live in RAM and the client that would expect one is gone.
1894+
if (segments)
1895+
saveChanges(segments, false);
1896+
flushChannelWarnings();
1897+
}
1898+
18781899
void AdminModule::saveChanges(int saveWhat, bool shouldReboot)
18791900
{
18801901
#ifdef PIO_UNIT_TESTING
@@ -1885,6 +1906,8 @@ void AdminModule::saveChanges(int saveWhat, bool shouldReboot)
18851906
service->reloadConfig(saveWhat); // Calls saveToDisk among other things
18861907
} else {
18871908
LOG_INFO("Delay save of changes to disk until the open transaction is committed");
1909+
editTransactionActivityMs = millis(); // still in use, so not the abandoned kind we time out
1910+
deferredEditSegments |= saveWhat;
18881911
}
18891912
if (shouldReboot && !hasOpenEditTransaction) {
18901913
reboot(DEFAULT_REBOOT_SECONDS);

src/modules/AdminModule.h

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,13 @@ class AdminModule : public ProtobufModule<meshtastic_AdminMessage>, public Obser
4040

4141
private:
4242
bool hasOpenEditTransaction = false;
43+
// Each deferred write restarts the clock, so this bounds the gap between writes, not the length
44+
// of the edit; a bulk import sends them milliseconds apart.
45+
static constexpr uint32_t EDIT_TRANSACTION_IDLE_MS = 60 * 1000;
46+
uint32_t editTransactionActivityMs = 0; // millis() of the last save this transaction deferred
47+
int deferredEditSegments = 0; // segments that transaction has touched but not yet saved
48+
/// Retire an open edit transaction whose client stopped talking, persisting what it applied.
49+
void expireStaleEditTransaction();
4350
#ifdef PIO_UNIT_TESTING
4451
int lastSaveWhatForTest = 0;
4552
#endif

test/support/AdminModuleTestShim.h

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -21,9 +21,18 @@ class AdminModuleTestShim : public AdminModule
2121
meshtastic_MeshPacket *reply() { return myReply; }
2222

2323
// With an "open edit transaction" saveChanges() is a pure no-op: no reloadConfig/saveToDisk/reboot.
24-
void deferSaves() { hasOpenEditTransaction = true; }
24+
// Stamps the clock like begin_edit_settings, so a slow suite can't age the transaction into expiry.
25+
void deferSaves()
26+
{
27+
hasOpenEditTransaction = true;
28+
editTransactionActivityMs = millis();
29+
}
2530
int savedSegments() const { return lastSaveWhatForTest; }
2631

32+
bool editTransactionOpen() const { return hasOpenEditTransaction; }
33+
// Backdate past the idle window so a test sees an abandoned transaction without waiting it out.
34+
void ageEditTransaction() { editTransactionActivityMs = millis() - EDIT_TRANSACTION_IDLE_MS - 1; }
35+
2736
// Setters may allocate an error reply from packetPool; drain it each iteration or the pool leaks.
2837
void drainReply()
2938
{

test/test_admin_radio/test_main.cpp

Lines changed: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1574,6 +1574,16 @@ static void sendCommitEdit()
15741574
sendAdmin(m);
15751575
}
15761576

1577+
// An admin message that changes nothing. It answers, so drain the reply or the packet pool leaks.
1578+
static void sendGetDeviceMetadata()
1579+
{
1580+
meshtastic_AdminMessage m = meshtastic_AdminMessage_init_zero;
1581+
m.which_payload_variant = meshtastic_AdminMessage_get_device_metadata_request_tag;
1582+
m.get_device_metadata_request = true;
1583+
sendAdmin(m);
1584+
testAdmin->drainReply();
1585+
}
1586+
15771587
// Preset = LongFast on US, unlicensed owner. "LongFast" is the display name we compare against.
15781588
static void usePresetLongFast()
15791589
{
@@ -1640,6 +1650,53 @@ static void test_warn_transaction_singleChannel_keepsSpecificMessage()
16401650
TEST_ASSERT_EQUAL_INT(0, warningsContaining("on channels"));
16411651
}
16421652

1653+
// An idle transaction is retired by the next admin message, flushing the warnings it held.
1654+
static void test_editTransaction_abandoned_isRetiredOnNextAdminMessage()
1655+
{
1656+
usePresetLongFast();
1657+
sendBeginEdit();
1658+
sendSetChannel(makeChannel(0, meshtastic_Channel_Role_PRIMARY, "long fast", DEFAULT_KEY, 1));
1659+
// Deferred, exactly as before: nothing emitted while the transaction looks alive.
1660+
TEST_ASSERT_EQUAL_INT(0, (int)capturedWarnings.size());
1661+
TEST_ASSERT_TRUE(testAdmin->editTransactionOpen());
1662+
1663+
testAdmin->ageEditTransaction();
1664+
sendGetDeviceMetadata(); // any later admin message, from any client
1665+
1666+
TEST_ASSERT_FALSE(testAdmin->editTransactionOpen());
1667+
TEST_ASSERT_EQUAL_INT(1, warningsContaining("looks like a mistype of 'LongFast'"));
1668+
}
1669+
1670+
// A write arriving after abandonment is saved, not deferred to a commit that never comes.
1671+
static void test_editTransaction_abandoned_laterWriteIsNoLongerDeferred()
1672+
{
1673+
usePresetLongFast();
1674+
sendBeginEdit();
1675+
testAdmin->ageEditTransaction();
1676+
1677+
sendSetChannel(makeChannel(0, meshtastic_Channel_Role_PRIMARY, "long fast", DEFAULT_KEY, 1));
1678+
1679+
// The write itself retired the stale transaction, so its own warning is emitted immediately.
1680+
TEST_ASSERT_FALSE(testAdmin->editTransactionOpen());
1681+
TEST_ASSERT_EQUAL_INT(1, warningsContaining("looks like a mistype of 'LongFast'"));
1682+
}
1683+
1684+
// A transaction still in use is left alone: each write refreshes the window.
1685+
static void test_editTransaction_active_isNotRetired()
1686+
{
1687+
usePresetLongFast();
1688+
sendBeginEdit();
1689+
sendSetChannel(makeChannel(0, meshtastic_Channel_Role_PRIMARY, "long fast", DEFAULT_KEY, 1));
1690+
sendSetChannel(makeChannel(1, meshtastic_Channel_Role_SECONDARY, "long fast", DEFAULT_KEY, 1));
1691+
1692+
TEST_ASSERT_TRUE(testAdmin->editTransactionOpen());
1693+
TEST_ASSERT_EQUAL_INT(0, (int)capturedWarnings.size());
1694+
1695+
sendCommitEdit();
1696+
TEST_ASSERT_FALSE(testAdmin->editTransactionOpen());
1697+
TEST_ASSERT_EQUAL_INT(1, warningsContaining("There may be name issues on channels 0, 1"));
1698+
}
1699+
16431700
static void test_warn_license_noTransaction_emittedImmediately()
16441701
{
16451702
usePresetLongFast();
@@ -1801,6 +1858,9 @@ void setup()
18011858
RUN_TEST(test_warn_cleanChannel_noMessage);
18021859
RUN_TEST(test_warn_transaction_multipleChannels_singleCoalescedMessage);
18031860
RUN_TEST(test_warn_transaction_singleChannel_keepsSpecificMessage);
1861+
RUN_TEST(test_editTransaction_abandoned_isRetiredOnNextAdminMessage);
1862+
RUN_TEST(test_editTransaction_abandoned_laterWriteIsNoLongerDeferred);
1863+
RUN_TEST(test_editTransaction_active_isNotRetired);
18041864
RUN_TEST(test_warn_license_noTransaction_emittedImmediately);
18051865
RUN_TEST(test_warn_license_transaction_coalescedToSingleMessage);
18061866

0 commit comments

Comments
 (0)