Fix duplicate extension config entries - #867
Conversation
dnzbk
left a comment
There was a problem hiding this comment.
Thanks for working on this!
It looks like this PR only masks the issue and likely only handles extension duplicates. It doesn't fix the root cause of why duplicate settings (like server settings) are generated in the first place
Could you investigate further to find where these duplicate entries originate and address the core issue?
SaveConfig previously rewrote every config line whose option name matched a save request entry, so duplicate lines accumulated by earlier versions (issue nzbgetcom#588) were preserved forever even though new appends were prevented. Write each option only once in the replacement path as well, so a single save converges a damaged config back to one line per option. Add a regression test covering exact and case-variant duplicate lines.
|
I did a full root-cause investigation of #588 using the reporter's attached configs and extension scripts, and reproduced the whole chain end to end against live daemons on v25.0, develop, and this branch.
TL;DR
1. The real defect is a first/last disagreementThree lookups return the first match:
But Everything below falls out of those two halves disagreeing. Duplicate lines are the symptom that makes it visible, not the bug itself. 2. The growth loop
N → 2N−1 per save. From a single duplicated entry: 2 → 3 → 5 → 9 → 17 → … → 2^k+1.
The reporter's config matches the recurrence exactly. The sanitized config attached to #588 (37,999 option lines):
Powers of two plus one are the signature of the 2N−1 loop, not of a client spamming entries linearly. The differing exponents just mean each group got its initial duplicate on a different save. Reproduced live. Seeded a 33-line config with one glitched duplicate, then ran four faithful On this branch the same experiment produces no growth at all. 3. Correction: the Settings page does not amplifyMy original write-up said the round-trip above is "what the Settings page does". That is wrong, and I want it on the record. Clicking Save all changes in the Web UI with zero edits leaves a 2-duplicate config at exactly 2. The amplifier needs a client that echoes 4. A single Settings-page save turns a disabled server back onThis one needs no API client and is, I think, the more serious half. The Web UI binds the first duplicate for display; the daemon runs on the last. So the Settings page shows a value the daemon isn't using, and saving persists the wrong one over the good line. Seed config: After one Save all changes, no edits: Measured on a live daemon:
On restart the empty value fails validation, 5. What this PR does nowThe original commit stopped the append side but kept every existing duplicate line, so a config damaged by v25 (the reporter's is 1 MB) would stay duplicated forever. I pushed an update to heal those — and re-testing showed that update had a defect of its own: It collapsed duplicates to the first occurrence's value while the loader uses the last. Before the dedup, the appended tail accidentally preserved the last value — which is why a damaged config still booted with the right setting. Removing the duplicates removed that accidental safety net and turned a latent inconsistency into real data loss, on exactly the configs the change exists to repair. Fixed by mirroring // Options::SetOption overwrites the existing entry while parsing, so when a
// name occurs on several lines the last one is the value nzbget runs on.
// Collapsing duplicates must preserve that value, otherwise saving a damaged
// config silently changes settings.
auto findLastValue = [optEntries](const char* name) -> const char*
{
for (auto it = optEntries->rbegin(); it != optEntries->rend(); ++it)
{
if (!strcasecmp(it->GetName(), name))
{
return it->GetValue();
}
}
return nullptr;
};Applied in both the replace loop and the append loop. Tests: added 6. Second origin bug: legacy script parser eats the first character of option namesUnchanged from my original analysis, and still worth a separate PR. The reporter's config contains
opt.name = line.substr(1, sepPos - 1);unconditionally — it assumes the option line starts with I reconstructed Character-for-character identical to the reporter's config entries, including the quoted default values ( Worth consideringA complementary change: have ReproductionEverything above comes from snapshots written by running daemons, not simulations. Method: seed a scratch config, drive One trap worth passing on: leftover nzbget daemons squat on control ports. A new daemon fails to bind, exits, and the RPC calls silently reach the old daemon — the run looks clean and means nothing. Worth asserting that the |
SaveConfig collapsed duplicate lines to the first matching entry's value, but Options::SetOption overwrites the entry in place while parsing, so the last line for a name is the value nzbget actually runs on. Healing a damaged config therefore discarded the live value and fell back to the option default on the next start - a disabled news server came back enabled. Resolve duplicates the same way the loader does: keep the first occurrence's name and position, but the last occurrence's value. Applied to both the replace and append paths. Extend the regression tests to assert the surviving value, not just the line count.
dnzbk
left a comment
There was a problem hiding this comment.
Since #588 is about where duplicate entries come from, the two causes you identified — the webui showing/saving the wrong value when duplicates exist, and the option-name mangling in ExtensionLoader - look in scope for this PR rather than follow-ups. Both are small fixes and resolving them here would actually close the issue. Could you include them?
| Options::OptEntry* optEntry = optEntries->FindOption(optname); | ||
| if (optEntry) | ||
| // write each option only once, dropping duplicate lines accumulated | ||
| // in the config file by earlier versions (issue #588); keep the |
There was a problem hiding this comment.
Please drop the (issue #588) references from the codebase.



Closes #588.
Problem
ScriptConfig::SaveConfigtracked saved options byOptEntrypointer.FindOptionresolves duplicate names case-insensitively to the first entry, so later same-name entries appeared unwritten and were appended repeatedly whenever settings were saved.That is the amplifier behind the exponential config growth in #588. With N duplicate lines of an option in the file, a client that echoes
loadconfigback sends all N entries (loadconfigreturns raw file lines, duplicates included), the replace loop rewrites the N existing lines, and the append loop adds N−1 more — N → 2N−1 per save, even though the client sent back exactly what it read. The reporter's attached config contains exactly the 2^k+1 copy counts this recurrence predicts (Server3.*×257,Server2.*×513,Category2-4options ×2049).There is a second, quieter half to the same defect.
SaveConfigwrote the first matching entry's value, whileOptions::SetOptionoverwrites the entry in place while parsing — so the last line for a name is the value nzbget actually runs on. The two disagreed. Before deduplication that went unnoticed, because the appended tail happened to preserve the last value; once duplicates are collapsed, writing the first value silently discards the live setting.Full analysis, measurements and reproduction steps are in the PR comments.
Fix
Scope
This fixes the growth and makes healing safe, but it does not cover one related path. The Settings page resolves duplicates to the first entry as well (
findOption, webui/config.js), so on an already-damaged config it displays a value the daemon is not using and persists that value on save. The correct value is discarded in the browser before the request reachesSaveConfig, so nothing here can recover it.The clean fix for that is to have
loadconfigreturn one entry per option name carrying the effective (last) value. That would correct the Settings page display, stop it from overwriting good lines, and prevent duplicates from ever reaching any client. I'd rather do it as a separate PR than widen this one — happy to take it.Verification
SaveConfigServer2.Active×17) converges after one save to a single line carryingno, the effective setting stays disabled, and no config error is logged; the same experiment on develop grows 2 → 3 → 5 → 9 → 17git diff --checkpasses