Skip to content

Commit 5e3dabb

Browse files
committed
Time out an abandoned admin edit transaction
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 an idle timeout. Each deferred write refreshes the clock; once it has sat untouched for the same 300s the session passkey uses, the next admin message retires it, 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. 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 1e982fa commit 5e3dabb

4 files changed

Lines changed: 122 additions & 2 deletions

File tree

src/modules/AdminModule.cpp

Lines changed: 36 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,11 @@ bool AdminModule::handleReceivedProtobuf(const meshtastic_MeshPacket &mp, meshta
240239
return handled;
241240
}
242241
}
242+
// This message is authorized, so it is a fair chance to notice that the client which opened an
243+
// edit transaction is never coming back. Do it before the switch, so every case below (and
244+
// everything they call) sees consistent transaction state.
245+
expireStaleEditTransaction();
246+
243247
switch (r->which_payload_variant) {
244248

245249
#ifdef MESHTASTIC_ENCRYPTED_STORAGE
@@ -481,12 +485,14 @@ bool AdminModule::handleReceivedProtobuf(const meshtastic_MeshPacket &mp, meshta
481485
case meshtastic_AdminMessage_begin_edit_settings_tag: {
482486
LOG_INFO("Begin transaction for editing settings");
483487
hasOpenEditTransaction = true;
488+
editTransactionActivityMs = millis();
484489
break;
485490
}
486491
case meshtastic_AdminMessage_commit_edit_settings_tag: {
487492
disableBluetooth();
488493
LOG_INFO("Commit transaction for edited settings");
489494
hasOpenEditTransaction = false;
495+
deferredEditSegments = 0;
490496
saveChanges(SEGMENT_CONFIG | SEGMENT_MODULECONFIG | SEGMENT_DEVICESTATE | SEGMENT_CHANNELS | SEGMENT_NODEDATABASE);
491497
flushChannelWarnings(); // one coalesced message for everything edited in this transaction
492498
break;
@@ -1875,6 +1881,32 @@ void AdminModule::reboot(int32_t seconds)
18751881
rebootAtMsec = (seconds < 0) ? 0 : (millis() + seconds * 1000);
18761882
}
18771883

1884+
// A begin_edit_settings whose commit never arrives -- the client dropped the link partway through a
1885+
// bulk config import, or simply went away -- used to leave the transaction open indefinitely. Every
1886+
// later config write, from any client, was then applied to RAM and acknowledged but never written to
1887+
// flash, so the node silently reverted all of it at the next boot and the only cure was a reboot.
1888+
//
1889+
// Bound how long an unattended transaction can hold saves hostage. Once one goes idle, persist what
1890+
// it already applied and let the node go back to saving normally. The window is refreshed by each
1891+
// write the transaction defers, so it only expires on a client that has genuinely stopped talking --
1892+
// long enough that a brief BLE drop and reconnect mid-import doesn't trip it.
1893+
void AdminModule::expireStaleEditTransaction()
1894+
{
1895+
if (!hasOpenEditTransaction || Throttle::isWithinTimespanMs(editTransactionActivityMs, kEditTransactionIdleMs))
1896+
return;
1897+
1898+
LOG_WARN("Edit transaction abandoned for %us; committing what it applied", kEditTransactionIdleMs / 1000);
1899+
hasOpenEditTransaction = false;
1900+
int segments = deferredEditSegments;
1901+
deferredEditSegments = 0;
1902+
// Persist exactly what the transaction deferred; a never-used transaction has nothing to save.
1903+
// No reboot: the settings are already live in RAM, and the client that would have expected one
1904+
// is gone.
1905+
if (segments)
1906+
saveChanges(segments, false);
1907+
flushChannelWarnings();
1908+
}
1909+
18781910
void AdminModule::saveChanges(int saveWhat, bool shouldReboot)
18791911
{
18801912
#ifdef PIO_UNIT_TESTING
@@ -1885,6 +1917,9 @@ void AdminModule::saveChanges(int saveWhat, bool shouldReboot)
18851917
service->reloadConfig(saveWhat); // Calls saveToDisk among other things
18861918
} else {
18871919
LOG_INFO("Delay save of changes to disk until the open transaction is committed");
1920+
// The transaction is still being used, so it isn't the abandoned kind we time out.
1921+
editTransactionActivityMs = millis();
1922+
deferredEditSegments |= saveWhat;
18881923
}
18891924
if (shouldReboot && !hasOpenEditTransaction) {
18901925
reboot(DEFAULT_REBOOT_SECONDS);

src/modules/AdminModule.h

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

4141
private:
4242
bool hasOpenEditTransaction = false;
43+
// How long an open edit transaction may sit idle before we assume its client is never coming
44+
// back. Matches the session-passkey window; far longer than any real begin/commit sequence,
45+
// including one carried over several mesh hops.
46+
static constexpr uint32_t kEditTransactionIdleMs = 300 * 1000;
47+
uint32_t editTransactionActivityMs = 0; // millis() of the last save this transaction deferred
48+
int deferredEditSegments = 0; // segments that transaction has touched but not yet saved
49+
/// Retire an open edit transaction whose client stopped talking, persisting what it applied.
50+
void expireStaleEditTransaction();
4351
#ifdef PIO_UNIT_TESTING
4452
int lastSaveWhatForTest = 0;
4553
#endif

test/support/AdminModuleTestShim.h

Lines changed: 13 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -21,9 +21,21 @@ 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 activity clock the way begin_edit_settings does, so a long-running suite can't age
25+
// the transaction into expiry mid-test.
26+
void deferSaves()
27+
{
28+
hasOpenEditTransaction = true;
29+
editTransactionActivityMs = millis();
30+
}
2531
int savedSegments() const { return lastSaveWhatForTest; }
2632

33+
bool editTransactionOpen() const { return hasOpenEditTransaction; }
34+
// Backdate the open transaction's activity clock past the idle window, so the next admin message
35+
// sees an abandoned transaction without the test waiting out the real timeout. The subtraction
36+
// wraps when millis() is small, which is exactly what Throttle's unsigned comparison expects.
37+
void ageEditTransaction() { editTransactionActivityMs = millis() - kEditTransactionIdleMs - 1; }
38+
2739
// Setters may allocate an error reply from packetPool; drain it each iteration or the pool leaks.
2840
void drainReply()
2941
{

test/test_admin_radio/test_main.cpp

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

1577+
// An admin message that changes nothing, for asserting on what merely receiving one does.
1578+
// It answers with a metadata response, so release the queued reply or the packet pool leaks.
1579+
static void sendGetDeviceMetadata()
1580+
{
1581+
meshtastic_AdminMessage m = meshtastic_AdminMessage_init_zero;
1582+
m.which_payload_variant = meshtastic_AdminMessage_get_device_metadata_request_tag;
1583+
m.get_device_metadata_request = true;
1584+
sendAdmin(m);
1585+
testAdmin->drainReply();
1586+
}
1587+
15771588
// Preset = LongFast on US, unlicensed owner. "LongFast" is the display name we compare against.
15781589
static void usePresetLongFast()
15791590
{
@@ -1640,6 +1651,57 @@ static void test_warn_transaction_singleChannel_keepsSpecificMessage()
16401651
TEST_ASSERT_EQUAL_INT(0, warningsContaining("on channels"));
16411652
}
16421653

1654+
// An edit transaction whose commit never arrives (client dropped the link mid-import) must not hold
1655+
// saves hostage forever. Once it goes idle, the next admin message retires it, persisting what it
1656+
// applied and flushing the warnings it was holding.
1657+
static void test_editTransaction_abandoned_isRetiredOnNextAdminMessage()
1658+
{
1659+
usePresetLongFast();
1660+
sendBeginEdit();
1661+
sendSetChannel(makeChannel(0, meshtastic_Channel_Role_PRIMARY, "long fast", DEFAULT_KEY, 1));
1662+
// Deferred, exactly as before: nothing emitted while the transaction looks alive.
1663+
TEST_ASSERT_EQUAL_INT(0, (int)capturedWarnings.size());
1664+
TEST_ASSERT_TRUE(testAdmin->editTransactionOpen());
1665+
1666+
testAdmin->ageEditTransaction();
1667+
sendGetDeviceMetadata(); // any later admin message, from any client
1668+
1669+
TEST_ASSERT_FALSE(testAdmin->editTransactionOpen());
1670+
TEST_ASSERT_EQUAL_INT(1, warningsContaining("looks like a mistype of 'LongFast'"));
1671+
}
1672+
1673+
// A later config write must be saved normally once the abandoned transaction is gone, instead of
1674+
// being silently deferred to a commit that is never coming.
1675+
static void test_editTransaction_abandoned_laterWriteIsNoLongerDeferred()
1676+
{
1677+
usePresetLongFast();
1678+
sendBeginEdit();
1679+
testAdmin->ageEditTransaction();
1680+
1681+
sendSetChannel(makeChannel(0, meshtastic_Channel_Role_PRIMARY, "long fast", DEFAULT_KEY, 1));
1682+
1683+
// The write itself retired the stale transaction, so its own warning is emitted immediately.
1684+
TEST_ASSERT_FALSE(testAdmin->editTransactionOpen());
1685+
TEST_ASSERT_EQUAL_INT(1, warningsContaining("looks like a mistype of 'LongFast'"));
1686+
}
1687+
1688+
// The timeout must not disturb a transaction that is still in use -- a bulk import sends many
1689+
// writes in a row, and each one refreshes the window.
1690+
static void test_editTransaction_active_isNotRetired()
1691+
{
1692+
usePresetLongFast();
1693+
sendBeginEdit();
1694+
sendSetChannel(makeChannel(0, meshtastic_Channel_Role_PRIMARY, "long fast", DEFAULT_KEY, 1));
1695+
sendSetChannel(makeChannel(1, meshtastic_Channel_Role_SECONDARY, "long fast", DEFAULT_KEY, 1));
1696+
1697+
TEST_ASSERT_TRUE(testAdmin->editTransactionOpen());
1698+
TEST_ASSERT_EQUAL_INT(0, (int)capturedWarnings.size());
1699+
1700+
sendCommitEdit();
1701+
TEST_ASSERT_FALSE(testAdmin->editTransactionOpen());
1702+
TEST_ASSERT_EQUAL_INT(1, warningsContaining("There may be name issues on channels 0, 1"));
1703+
}
1704+
16431705
static void test_warn_license_noTransaction_emittedImmediately()
16441706
{
16451707
usePresetLongFast();
@@ -1801,6 +1863,9 @@ void setup()
18011863
RUN_TEST(test_warn_cleanChannel_noMessage);
18021864
RUN_TEST(test_warn_transaction_multipleChannels_singleCoalescedMessage);
18031865
RUN_TEST(test_warn_transaction_singleChannel_keepsSpecificMessage);
1866+
RUN_TEST(test_editTransaction_abandoned_isRetiredOnNextAdminMessage);
1867+
RUN_TEST(test_editTransaction_abandoned_laterWriteIsNoLongerDeferred);
1868+
RUN_TEST(test_editTransaction_active_isNotRetired);
18041869
RUN_TEST(test_warn_license_noTransaction_emittedImmediately);
18051870
RUN_TEST(test_warn_license_transaction_coalescedToSingleMessage);
18061871

0 commit comments

Comments
 (0)