Skip to content

Commit a0d21be

Browse files
committed
Stop the edit transaction discarding the per-field save decisions
Review of this branch's own output, plus the two findings Copilot raised on The headline defect is that none of this branch's narrowing reached phone apps. saveChanges() defers while an edit transaction is open, and radioAffected defaulted to true, so eight call sites that never thought about the radio - the five node-DB handlers, set_owner, set_module_config, and the nested save in the position case - accumulated a true into deferredRadioAffected. Outside a transaction that was harmless, because reloadConfig()'s saveWhat & (SEGMENT_CONFIG | SEGMENT_CHANNELS) bitmask independently blocks a node-DB-only save from reaching the radio whatever it asks for. Inside one the commit saves under a fixed full mask, so the bitmask always passes and radioAffected is the only thing left deciding it. Favouriting a node from the phone app therefore still ran the live SX126x reconfigure at commit - the The fix is to remove the default from saveChanges() rather than gate the deferred flag on the segment mask, which is what the review suggested. Gating would reinstate exactly the segment-to-radio inference this branch exists to delete - NodeDB.h now carries a comment telling the next person not to do that - and it fixes the symptom while leaving the wrong default in place for the next call site. With no default the compiler makes all fourteen sites state the answer. The commit also consumes and clears the deferred flags, so a stray second commit cannot inherit the previous transaction's answer. That gap existed because every radio test ran outside a transaction, which is the one arrangement where the bug is unreachable. Nine tests now cover the deferred path, asserting both axes independently across a commit. Verified they fail against the old behaviour: five go red while every pre-existing test stays green, which is the point. requestReboot()'s comment had the semantics backwards - it claimed a negative delay meant "now" and that rebootAtMsec == 0 was an immediate-reboot sentinel. Both are inverted: 0 means no reboot pending at every read site, and admin.proto documents reboot_seconds "<0 to cancel reboot". The expression was a faithful copy of AdminModule's, so only the comment was wrong, but it was wrong on the newly-created central helper. The negative branch now says so and logs it. Five sites still open-coded the deadline despite that helper existing; they are pure reboots with no config save, so applyConfigChange() does not fit but requestReboot() does exactly. SET_SMART_BROADCAST_INTERVAL was the only CONFIG_APPLY_REBOOT site in the InkHUD menu without a notifyApplyingChanges() beside it - re-adding its reboot last commit dropped the warning that applyConfigReload() used to raise, so the e-ink would go dark unannounced. And four channel actions carried CONFIG_APPLY_RADIO for uplink/downlink and position_precision, none of which touch the name, PSK or frequency slot the radio derives anything from; they had it only because the old reloadConfig(SEGMENT_CHANNELS) inferred it from the bitmask. The doc had gone stale inside its own PR again: it still listed commit_edit_settings under "intentionally left unchanged". Replaced with a section on why the bitmask stops protecting you inside a transaction, since that is the non-obvious part. Also dropped a commit SHA that had already been orphaned by rebase, exactly as its own note predicted, and a stale plan-doc reference in the tests that the last doc pass missed. Full native suite green, 810 cases across 40 suites. t-echo and t-echo-inkhud both build, covering the menu changes no native env compiles.
1 parent 8059c0f commit a0d21be

9 files changed

Lines changed: 397 additions & 53 deletions

File tree

docs/admin-config-save-gating.md

Lines changed: 77 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -53,18 +53,24 @@ if (radioAffected && (saveWhat & (SEGMENT_CONFIG | SEGMENT_CHANNELS)))
5353
```
5454

5555
The bitmask gate is **retained** (so the ~35 non-AdminModule callers are unaffected) and
56-
`radioAffected` only ever _suppresses_ the reload. It defaults to `true`, so any caller that
57-
does not opt out keeps the historical behavior. `AdminModule::saveChanges`
58-
([`:1846`](../src/modules/AdminModule.cpp#L1846)) threads it through; `handleSetConfig`
59-
([`:836`](../src/modules/AdminModule.cpp#L836)) sets `radioAffected = true`
60-
([`:845`](../src/modules/AdminModule.cpp#L845)) only in the `lora_tag` case.
56+
`radioAffected` only ever _suppresses_ the reload. `MeshService::reloadConfig` still defaults it
57+
to `true`, so any external caller that does not opt out keeps the historical behavior.
58+
59+
`AdminModule::saveChanges(saveWhat, shouldReboot, radioAffected)` threads it through, and there it
60+
has **no default** - every one of its call sites states the answer. That is not stylistic: see
61+
"Edit transactions" below for why an inherited `true` stops being harmless once a transaction is
62+
open. `handleSetConfig` sets `radioAffected = true` only in the `lora_tag` case.
6163

6264
| Admin operation | Reloads radio now? | Notes |
6365
| ----------------------------------------------------------------------- | ------------------ | -------------------------------------------------------------------------------------------------------- |
6466
| `set_config``lora` | **Yes** | The only config that feeds `RadioInterface::reconfigure()` (reads `config.lora`). Region swaps included. |
6567
| `set_config` → device/position/power/network/display/bluetooth/security | **No** (was yes) | Persist `SEGMENT_CONFIG`, but none affect the modem. |
6668
| `set_fixed_position` / `remove_fixed_position` | **No** (was yes) | Rode `SEGMENT_CONFIG` only because `fixed_position` shares the Config file. |
67-
| `set_channel` | **Yes** | Real frequency-slot/PSK change ([`:1445`](../src/modules/AdminModule.cpp#L1445)). |
69+
| `set_module_config` | **No** | No module config feeds `reconfigure()`. Previously only the segment mask stopped it. |
70+
| `set_owner` | **No** | Node metadata. Still reboots (pre-existing) - that axis was left alone. |
71+
| favorite / ignore / mute node | **No** | Node-DB bits. The #11146 crash path. |
72+
| `set_channel` | **Yes** | Real frequency-slot/PSK change. |
73+
| `set_ham_mode` | **Yes** | Rewrites `config.lora.tx_enabled` and strips channel PSKs. |
6874

6975
Why this matters beyond tidiness: the reconfigure is a live `setStandby()` + SPI reprogram. It
7076
is _invoked_ from the admin-message handler on the main task (BLE `onWrite` only queues, so
@@ -121,24 +127,54 @@ for schema growth.
121127

122128
---
123129

130+
## Edit transactions - where both axes nearly got lost
131+
132+
Read this before adding a `saveChanges()` call site.
133+
134+
A client may bracket a run of admin messages in `begin_edit_settings` / `commit_edit_settings`.
135+
While the transaction is open, `saveChanges()` **defers**: it writes nothing and returns, so a
136+
multi-field set doesn't hit flash once per field. The commit then performs a single save under a
137+
**fixed full-segment mask**.
138+
139+
Two consequences, both easy to miss:
140+
141+
1. **The per-field decisions had nowhere to go.** Everything the two axes above compute happens
142+
inside `handleSetConfig`, whose `saveChanges()` call is the one being deferred. The commit's
143+
own `saveChanges()` originally passed nothing, so it took the parameter defaults - reboot and
144+
reconfigure, on every commit, whatever was edited. Phone apps write config through
145+
transactions, so **none of the narrowing above reached them.** `deferredShouldReboot` and
146+
`deferredRadioAffected` now accumulate (`|=`) the deferred answers; the commit consumes them
147+
and clears them, so a stray second commit cannot inherit the previous transaction's answer.
148+
149+
2. **The segment bitmask stops protecting you.** Outside a transaction, a node-DB-only save
150+
physically cannot reach the radio: `saveWhat & (SEGMENT_CONFIG | SEGMENT_CHANNELS)` is zero
151+
whatever `radioAffected` says. Inside one, the commit saves under the full mask, so that gate
152+
always passes and `radioAffected` becomes the _only_ thing deciding it. An accidental `true` -
153+
inherited from a default, on a call site that never thought about the radio - then reaches the
154+
live SX126x reconfigure at commit. Favoriting a node from the phone app would have gone
155+
straight back through the #11146 crash path.
156+
157+
That is why `AdminModule::saveChanges` gives `radioAffected` **no default**. Do not add one
158+
back. The nested `saveChanges()` inside the `position_tag` case is the subtle instance: an
159+
inner call on an unrelated segment can opt the whole transaction in.
160+
161+
Covered natively: see the "Edit-transaction deferral" block in `test_admin_radio`, which asserts
162+
both axes independently across a commit (node-DB, module-config and nested-position batches
163+
reconfigure nothing; a LoRa batch still does; a GPS-mode batch reboots without reconfiguring).
164+
165+
---
166+
124167
## Operations intentionally left unchanged ("third tier")
125168

126169
These were audited and deliberately **not** narrowed. Each still reloads and/or reboots as
127170
before. The common thread: the reload/reboot is either genuinely required, or removing it
128171
would need real state-tracking / hardware verification that carries more risk than the saving
129172
is worth.
130173

131-
1. **`commit_edit_settings`** ([`:474`](../src/modules/AdminModule.cpp#L474)) - commits a
132-
whole edit transaction with a fixed
133-
`SEGMENT_CONFIG | SEGMENT_MODULECONFIG | SEGMENT_DEVICESTATE | SEGMENT_CHANNELS | SEGMENT_NODEDATABASE`
134-
save (default `radioAffected=true`, `shouldReboot=true`), so it reloads and reboots on
135-
**every** commit regardless of what was actually edited.
136-
**Why untouched:** narrowing it requires the transaction to track _which_ segments were
137-
dirtied (and whether LoRa/reboot-relevant fields were among them) as individual sets ran
138-
under `hasOpenEditTransaction`, then OR them at commit - a real state-tracking change, not
139-
a one-line gate. Risk is also lower here: edit transactions are rarely
140-
node-DB-metadata-only, so the wasteful case is less common. Deferred to its own pass; the
141-
same tracking would let it decide both `radioAffected` and `requiresReboot`.
174+
1. ~~**`commit_edit_settings`**~~ - **now done, see "Edit transactions" below.** Initially
175+
deferred to its own pass (it needed real state-tracking rather than a one-line gate), then
176+
done in the same PR once it became clear the deferral was discarding every decision the rest
177+
of this work computes.
142178

143179
2. **`network` / `bluetooth` beyond the Tier-1 no-op gate** - a _real_ change still reboots.
144180
**Why untouched:** these restart the WiFi/Ethernet and BLE stacks. Applying a live change
@@ -201,7 +237,16 @@ Notes for anyone extending this:
201237
the notice at draw time from `rebootAtMsec`, while InkHUD's e-ink only draws when pushed and so
202238
raises `notifyApplyingChanges()` explicitly.
203239
- `rebootSeconds` exists for one caller - InkHUD's wifi-recovery path uses a shorter delay than
204-
`DEFAULT_REBOOT_SECONDS`.
240+
`DEFAULT_REBOOT_SECONDS`. A **negative** value cancels a pending reboot rather than bringing it
241+
forward (`rebootAtMsec == 0` means "none pending" everywhere it is read), matching
242+
`admin.proto`'s `reboot_seconds`.
243+
- Every `CONFIG_APPLY_REBOOT` site in the InkHUD menu pairs with a `notifyApplyingChanges()`, and
244+
must keep doing so - see the two-jobs note on that method in `InkHUD.h`.
245+
- `CONFIG_APPLY_RADIO` means the **modem**, not the segment. The channel menu's uplink/downlink
246+
and `position_precision` actions write `SEGMENT_CHANNELS` but touch no name, PSK or frequency
247+
slot, so they persist only; the frequency the radio derives comes from the name+PSK hash, and
248+
`RadioInterface` is the sole observer of `configChanged`. They carried the flag at first purely
249+
because the old `reloadConfig(SEGMENT_CHANNELS)` inferred it from the bitmask.
205250
- UI-only state stays out of this: BaseUI `uiconfig` fields use `saveUIConfig()`, and InkHUD has
206251
its own non-protobuf `Persistence::Settings` store. Neither is in the `/prefs` protobuf tree.
207252
@@ -217,6 +262,12 @@ Native coverage lives in `test/test_admin_radio/test_main.cpp`:
217262
- **Reboot:** each of position/network/bluetooth asserts `rebootAtMsec` stays `0` on a no-op
218263
set and is armed on a real change; a position broadcast-interval change asserts **no**
219264
reboot, while `gps_mode` and `rx_gpio` changes assert a reboot.
265+
- **Edit transactions:** the same two axes asserted across a `begin` / set / `commit` sequence,
266+
where the segment bitmask no longer backstops `radioAffected`. Node-DB, module-config and
267+
nested-position batches must commit without reconfiguring; a LoRa batch must still reconfigure
268+
(and not reboot); a `gps_mode` batch must reboot without reconfiguring. Also pinned: nothing is
269+
written until the commit, the flags don't leak into the next transaction, and a commit with no
270+
matching begin is inert.
220271
221272
The harness defers `reboot()` to `rebootAtMsec` (it does not exit), which is the signal the
222273
reboot tests assert on.
@@ -245,7 +296,7 @@ thread under test are the same whichever transport you use, and the original cra
245296
serial-proven. Drive the admin messages over USB serial (e.g. the `meshtastic --port` CLI);
246297
use BLE only if you specifically want to reproduce the exact user-facing conditions.
247298
248-
### 1. Radio-reload / crash validation (regression guard for `babeef08d`)
299+
### 1. Radio-reload / crash validation (regression guard for the favorite-node fix)
249300
250301
Confirms the non-LoRa config saves no longer run the live radio reconfigure - the main-task
251302
`setStandby()` + SPI reprogram that collided with the radio's off-main worker thread over the
@@ -262,6 +313,12 @@ keypair, favorite/unfavorite a node.
262313
- **Positive control:** a `lora` config change and a `set_channel` **should** show the
263314
reconfigure - proves the path still fires when it should.
264315
316+
**Then repeat the favorite/unfavorite case wrapped in `begin_edit_settings` /
317+
`commit_edit_settings`** - that is the shape a phone app actually sends, and until the deferred
318+
flags landed it was the one shape where the segment bitmask could not stop the reconfigure (see
319+
"Edit transactions"). Pass criteria are the same, measured at the commit. Watch for a
320+
reconfigure that appears only at the commit, not during the individual sets.
321+
265322
### 2. Position live-apply - expanding the Tier-2 live set (outstanding)
266323
267324
The only reason to touch a node for the _reboot_ work: decide whether the GPS-timing fields
@@ -290,7 +347,7 @@ a cheap confidence test but not a gate.
290347
- To stop a config field from reloading the radio on an **AdminModule/client config save**: it
291348
already doesn't, unless it's `lora`. Do **not** widen `radioAffected` to non-LoRa config -
292349
only `RadioInterface::reconfigure()` (which reads `config.lora`) consumes it. The on-device
293-
menus are now gated the same way, via `applyConfigChange` (see below).
350+
menus are now gated the same way, via `applyConfigChange` (see "On-device menus" above).
294351
- To move a field off the reboot path: confirm it is consumed live (read from `config` at use
295352
time, no cached/driver state), add it to the live set in the relevant `handleSetConfig`
296353
case, and add a native case asserting no reboot on its change. For anything driver- or

src/graphics/draw/MenuHandler.cpp

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1705,12 +1705,12 @@ void menuHandler::resetNodeDBMenu()
17051705
LOG_INFO("Initiate node-db reset");
17061706
nodeDB->resetNodes();
17071707
disableBluetooth();
1708-
rebootAtMsec = (millis() + DEFAULT_REBOOT_SECONDS * 1000);
1708+
requestReboot();
17091709
} else if (selected == 2) {
17101710
LOG_INFO("Initiate node-db reset but keeping favorites");
17111711
nodeDB->resetNodes(1);
17121712
disableBluetooth();
1713-
rebootAtMsec = (millis() + DEFAULT_REBOOT_SECONDS * 1000);
1713+
requestReboot();
17141714
} else if (selected == 0) {
17151715
menuQueue = NodeBaseMenu;
17161716
screen->runNow();
@@ -2208,7 +2208,7 @@ void menuHandler::rebootMenu()
22082208
IF_SCREEN(screen->showSimpleBanner("Rebooting...", 0));
22092209
nodeDB->saveToDisk();
22102210
messageStore.saveToFlash();
2211-
rebootAtMsec = millis() + DEFAULT_REBOOT_SECONDS * 1000;
2211+
requestReboot();
22122212
} else {
22132213
menuQueue = PowerMenu;
22142214
screen->runNow();
@@ -2940,7 +2940,7 @@ void menuHandler::toggleNodeMuted(uint32_t nodeNum)
29402940

29412941
const bool wasMuted = nodeInfoLiteIsMuted(n);
29422942
nodeInfoLiteSetBit(n, NODEINFO_BITFIELD_IS_MUTED_MASK, !wasMuted);
2943-
LOG_INFO(wasMuted ? "Unmuted node %08X" : "Muted node %08X", nodeNum);
2943+
LOG_INFO(wasMuted ? "Unmuted node 0x%08x" : "Muted node 0x%08x", nodeNum);
29442944
nodeDB->notifyObservers(true);
29452945
nodeDB->saveToDisk(SEGMENT_NODEDATABASE); // NodeInfoLite bit: only the node DB changed
29462946
}

src/graphics/niche/InkHUD/Applets/System/Menu/MenuApplet.cpp

Lines changed: 18 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -589,6 +589,10 @@ void InkHUD::MenuApplet::execute(MenuItem item)
589589
break;
590590

591591
case TOGGLE_GPS:
592+
// The gps->enable()/disable() calls below are new: this used to write gps_mode and reload
593+
// the radio, leaving the driver in its old state until the next boot. Driving the driver
594+
// here is what makes CONFIG_APPLY_NONE correct - it matches MenuHandler::GPSToggleMenu(),
595+
// which has always done it this way.
592596
#if !MESHTASTIC_EXCLUDE_GPS && HAS_GPS
593597
if (config.position.gps_mode == meshtastic_Config_PositionConfig_GpsMode_DISABLED) {
594598
config.position.gps_mode = meshtastic_Config_PositionConfig_GpsMode_ENABLED;
@@ -638,6 +642,7 @@ void InkHUD::MenuApplet::execute(MenuItem item)
638642
// AdminModule path, which also reboots for this field.
639643
config.position.broadcast_smart_minimum_interval_secs = SMART_INTERVAL_OPTIONS[index].value;
640644
service->applyConfigChange(SEGMENT_CONFIG, CONFIG_APPLY_REBOOT);
645+
InkHUD::InkHUD::getInstance()->notifyApplyingChanges();
641646
}
642647
break;
643648
}
@@ -1062,17 +1067,24 @@ void InkHUD::MenuApplet::execute(MenuItem item)
10621067
break;
10631068

10641069
// Channels
1070+
//
1071+
// These four only touch a channel's uplink/downlink MQTT flags or its position_precision -
1072+
// never the name, PSK or frequency slot. The channel hash that RadioInterface::reconfigure()
1073+
// derives the frequency from is computed from name+PSK only, and RadioInterface is the sole
1074+
// observer of configChanged, so there is nothing for a radio reload to do here. They carried
1075+
// CONFIG_APPLY_RADIO purely because the old reloadConfig(SEGMENT_CHANNELS) inferred it from
1076+
// the bitmask. Persist only, like the BaseUI channel-mute action.
10651077
case TOGGLE_CHANNEL_UPLINK: {
10661078
auto &ch = channels.getByIndex(selectedChannelIndex);
10671079
ch.settings.uplink_enabled = !ch.settings.uplink_enabled;
1068-
service->applyConfigChange(SEGMENT_CHANNELS, CONFIG_APPLY_RADIO);
1080+
service->applyConfigChange(SEGMENT_CHANNELS, CONFIG_APPLY_NONE);
10691081
break;
10701082
}
10711083

10721084
case TOGGLE_CHANNEL_DOWNLINK: {
10731085
auto &ch = channels.getByIndex(selectedChannelIndex);
10741086
ch.settings.downlink_enabled = !ch.settings.downlink_enabled;
1075-
service->applyConfigChange(SEGMENT_CHANNELS, CONFIG_APPLY_RADIO);
1087+
service->applyConfigChange(SEGMENT_CHANNELS, CONFIG_APPLY_NONE);
10761088
break;
10771089
}
10781090

@@ -1087,7 +1099,7 @@ void InkHUD::MenuApplet::execute(MenuItem item)
10871099
else
10881100
ch.settings.module_settings.position_precision = 13; // default
10891101

1090-
service->applyConfigChange(SEGMENT_CHANNELS, CONFIG_APPLY_RADIO);
1102+
service->applyConfigChange(SEGMENT_CHANNELS, CONFIG_APPLY_NONE);
10911103
break;
10921104
}
10931105

@@ -1106,20 +1118,20 @@ void InkHUD::MenuApplet::execute(MenuItem item)
11061118
ch.settings.module_settings.position_precision = POSITION_PRECISION_OPTIONS[index].value;
11071119
}
11081120

1109-
service->applyConfigChange(SEGMENT_CHANNELS, CONFIG_APPLY_RADIO);
1121+
service->applyConfigChange(SEGMENT_CHANNELS, CONFIG_APPLY_NONE);
11101122
break;
11111123
}
11121124

11131125
case RESET_NODEDB_ALL:
11141126
InkHUD::getInstance()->notifyApplyingChanges();
11151127
nodeDB->resetNodes();
1116-
rebootAtMsec = millis() + DEFAULT_REBOOT_SECONDS * 1000;
1128+
requestReboot();
11171129
break;
11181130

11191131
case RESET_NODEDB_KEEP_FAVORITES:
11201132
InkHUD::getInstance()->notifyApplyingChanges();
11211133
nodeDB->resetNodes(1);
1122-
rebootAtMsec = millis() + DEFAULT_REBOOT_SECONDS * 1000;
1134+
requestReboot();
11231135
break;
11241136

11251137
case WIPE_MESSAGES_ALL:

src/main.cpp

Lines changed: 11 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1230,10 +1230,18 @@ bool suppressRebootBanner; // If true, suppress "Rebooting..." overlay (used for
12301230

12311231
void requestReboot(int32_t seconds)
12321232
{
1233+
// rebootAtMsec == 0 means "no reboot pending" everywhere it is read (Power::powerCommandsCheck
1234+
// tests `if (rebootAtMsec && ...)`, and both the reboot banner and the portduino/lockdown
1235+
// checks treat 0 as idle). So a negative delay *cancels* a pending reboot rather than bringing
1236+
// it forward - which is what admin.proto documents for reboot_seconds ("<0 to cancel reboot").
1237+
// Menu callers always pass a positive; the branch exists for that remote-admin cancel.
1238+
if (seconds < 0) {
1239+
LOG_INFO("Reboot cancelled");
1240+
rebootAtMsec = 0;
1241+
return;
1242+
}
12331243
LOG_INFO("Reboot in %d seconds", seconds);
1234-
// A negative delay means "now"; rebootAtMsec == 0 is the immediate-reboot sentinel. Remote
1235-
// admin relies on this, so keep the branch even though menu callers always pass a positive.
1236-
rebootAtMsec = (seconds < 0) ? 0 : (millis() + seconds * 1000);
1244+
rebootAtMsec = millis() + seconds * 1000;
12371245
}
12381246

12391247
#if defined(MESHTASTIC_ENCRYPTED_STORAGE) && defined(MESHTASTIC_PHONEAPI_ACCESS_CONTROL)

src/main.h

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -93,7 +93,8 @@ extern uint32_t rebootAtMsec;
9393
extern uint32_t shutdownAtMsec;
9494
extern bool suppressRebootBanner;
9595

96-
/** Schedule a reboot `seconds` from now, or immediately if `seconds` is negative.
96+
/** Schedule a reboot `seconds` from now. A negative `seconds` *cancels* a pending reboot, matching
97+
* admin.proto's reboot_seconds ("<0 to cancel reboot") - it does not reboot immediately.
9798
*
9899
* Deliberately carries no UI: BaseUI already renders the notice at draw time whenever
99100
* rebootAtMsec is set and suppressRebootBanner is clear (see Screen.cpp), so raising a banner

src/mesh/NodeDB.h

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -92,6 +92,8 @@ static const char LICENSED_IDENTITY_MIGRATION_WARNING[] =
9292
#define SEGMENT_CHANNELS 8
9393
#define SEGMENT_NODEDATABASE 16
9494

95+
// DeviceState versions used to be defined in the .proto file but really only this function cares.
96+
// So changed to a #define here.
9597
#define DEVICESTATE_CUR_VER 25
9698
// Lowest on-disk version we still know how to load. v24 saves are migrated
9799
// at boot via the parallel deviceonly_legacy descriptor and re-saved as v25.

0 commit comments

Comments
 (0)