Skip to content

fix(net): guard ensureOwnedPanadapter cap path with AdaptiveThrottleEnabled check#3201

Merged
ten9876 merged 1 commit into
aethersdr:mainfrom
chibondking:fix/adaptive-throttle-local-toggle
May 27, 2026
Merged

fix(net): guard ensureOwnedPanadapter cap path with AdaptiveThrottleEnabled check#3201
ten9876 merged 1 commit into
aethersdr:mainfrom
chibondking:fix/adaptive-throttle-local-toggle

Conversation

@chibondking
Copy link
Copy Markdown
Collaborator

@chibondking chibondking commented May 27, 2026

Summary

Closes a gap in the adaptive frame-rate throttle toggle introduced in #3175.

When a panadapter was opened mid-session while the network was already in a degraded state, ensureOwnedPanadapter() applied the active fps cap directly via sendAdaptiveCapToPan() without consulting AdaptiveThrottleEnabled. The toggle was bypassed for newly-registered pans, including the deferred waterfallIdChanged re-apply lambda.

Approach: gate at the source

Rather than adding inline checks at each call site (which multiplies the guard and creates a latent risk for future callers), the fix moves the enable check into currentAdaptiveFpsCap():

int RadioModel::currentAdaptiveFpsCap() const
{
    if (AppSettings::instance().value("AdaptiveThrottleEnabled", "False").toString() != "True")
        return 0;
    return fpsCapForState(m_netState);
}

ensureOwnedPanadapter() already gates on activeCap > 0 — it gets correct behaviour for free with no other changes. applyAdaptiveFrameRate() keeps its own early-return as a short-circuit (it calls fpsCapForState directly, not currentAdaptiveFpsCap).

All sendAdaptiveCapToPan call sites now covered

Location How gated
applyAdaptiveFrameRate() explicit early-return (short-circuit; calls fpsCapForState directly)
ensureOwnedPanadapter() immediate cap activeCap > 0currentAdaptiveFpsCap() returns 0 when off
ensureOwnedPanadapter() waterfallIdChanged lambda same — cap > 0 check calls currentAdaptiveFpsCap()

Additional nits addressed

  • Qt::UniqueConnection added to the waterfallIdChanged lambda to prevent duplicate cap commands if waterfallId changes more than once on a pan
  • Comment added documenting the narrow ordering edge case: pan created healthy → network degrades → waterfallId arrives before next tier transition. applyAdaptiveFrameRate() catches it on the next transition; noted, not fixed.

🤖 Generated with Claude Code

@chibondking chibondking requested a review from a team as a code owner May 27, 2026 01:08
@chibondking
Copy link
Copy Markdown
Collaborator Author

@AetherClaude critical thoughts on this change?

@aethersdr-agent
Copy link
Copy Markdown
Contributor

Critical thoughts on the proposed fix:

The bug is real. Confirmed at src/models/RadioModel.cpp:3422-3434ensureOwnedPanadapter() reads currentAdaptiveFpsCap() and pushes it to the new pan with no AdaptiveThrottleEnabled check, while applyAdaptiveFrameRate() at line 2806 does gate on it. The asymmetry is exactly as described, and the repro is plausible.

My main concern: the proposed shape multiplies the guard rather than eliminating it. After the fix, AdaptiveThrottleEnabled is consulted at three separate sites (line 2806, the immediate cap in ensureOwnedPanadapter, and the waterfallIdChanged lambda). The lambda capturing an inline settings re-read is the part that worries me — every future toggle-aware caller has to remember to add its own check, and the existing comment at line 2765 already warns that fps-cap logic must funnel through one place to avoid silent divergence (it just happens to be about fpsCapForState, not the enable gate).

Cleaner alternative — gate at the source:

int RadioModel::currentAdaptiveFpsCap() const
{
    if (AppSettings::instance().value("AdaptiveThrottleEnabled", "False").toString() != "True")
        return 0;
    return fpsCapForState(m_netState);
}

Then applyAdaptiveFrameRate() can drop its explicit early-return (or keep it as a short-circuit), and ensureOwnedPanadapter() needs no changes at all — activeCap > 0 already gates both the immediate push and the lambda connection. The semantics become "the cap that should currently apply" rather than "the cap implied by net tier," which matches how all three call sites actually use the value.

One thing the proposed fix gets right that the alternative doesn't: the waterfallIdChanged lambda re-reads settings each time it fires, so toggling the setting after the pan is created behaves correctly on the next waterfall ID change. The single-point fix preserves that too (the lambda still calls currentAdaptiveFpsCap() fresh).

Smaller nits regardless of which shape lands:

  • The connect() for the waterfall re-apply is only made when activeCap > 0 at pan creation. If a pan is created in a healthy state, then the network degrades, then the waterfall ID arrives — no re-apply. applyAdaptiveFrameRate() will catch it on the next tier transition, but it's a latent ordering quirk worth a comment if not a fix.
  • The waterfallIdChanged connection has no Qt::UniqueConnection and is never disconnected; if waterfallId changes more than once on a pan you'll send duplicate commands. Probably benign but worth noting.

If you want the single-point version, a maintainer can authorize the orchestrator to land it by adding the aetherclaude-eligible label.

73, Jeremy KK7GWY & Claude (AI dev partner)


🤖 aethersdr-agent · cost: $1.5819 · model: claude-opus-4-7

…Cap()

When a panadapter was opened mid-session while the network was already
in a degraded state, ensureOwnedPanadapter() applied the active fps cap
directly via sendAdaptiveCapToPan() without consulting AdaptiveThrottleEnabled.
The toggle was bypassed entirely for newly-registered pans, including the
deferred waterfallIdChanged re-apply.

Fix: gate at the source by returning 0 from currentAdaptiveFpsCap() when
AdaptiveThrottleEnabled is off. ensureOwnedPanadapter() already gates on
activeCap > 0, so it gets correct behaviour for free with no inline checks.
applyAdaptiveFrameRate() keeps its own early-return as a short-circuit
(it calls fpsCapForState directly, not currentAdaptiveFpsCap).

Also address two nits in ensureOwnedPanadapter:
- Add Qt::UniqueConnection to the waterfallIdChanged lambda to prevent
  duplicate cap commands if waterfallId changes more than once on a pan.
- Add a comment noting the ordering edge case: if a pan is created healthy,
  the network degrades, and waterfallId arrives before the next tier
  transition, no re-apply is made. applyAdaptiveFrameRate() catches it on
  the next transition. Noted, not fixed, as it is a narrow ordering window
  with a bounded recovery time.
@chibondking chibondking force-pushed the fix/adaptive-throttle-local-toggle branch from 2c44492 to 7096cd5 Compare May 27, 2026 01:12
@chibondking
Copy link
Copy Markdown
Collaborator Author

@AetherClaude fixes pushed to address your concern. Anything I am missing now?

@aethersdr-agent
Copy link
Copy Markdown
Contributor

Re-reviewed against main head — the patch lands the gate-at-source shape cleanly. Verified:

currentAdaptiveFpsCap() (RadioModel.cpp:2779-2783) now returns 0 when AdaptiveThrottleEnabled is off. Single point of truth, exactly as proposed.

All three sendAdaptiveCapToPan call sites are covered:

Site Gate
applyAdaptiveFrameRate() line 2821 explicit early-return at 2806 (calls fpsCapForState directly, so its own gate stays)
ensureOwnedPanadapter() immediate push, line 3426 activeCap > 0 — gets 0 from currentAdaptiveFpsCap() when off
waterfallIdChanged lambda, line 3432 re-reads via currentAdaptiveFpsCap(), gated by cap > 0

Nits both addressed:

  • Qt::UniqueConnection on the lambda — prevents duplicate cap commands on repeat waterfallId changes.
  • Comment at 3422-3425 documents the ordering edge case (pan created healthy → degrades → waterfallId arrives before next tier transition).

Toggle-OFF-while-degraded behavior is unchanged and is consistent with the existing tooltip in ConnectionPanel.cpp:567-571 ("Toggling while connected takes effect at the next quality update; reconnect to lift an already-applied cap immediately"). Out of scope here.

Bonus property of this shape: because the lambda re-reads currentAdaptiveFpsCap() fresh each time it fires, a pan opened during a brief degraded window that flips healthy before waterfallId arrives will correctly skip the cap push. That falls out for free from gating at the source.

Nothing missing from my side — LGTM.

73, Jeremy KK7GWY & Claude (AI dev partner)


🤖 aethersdr-agent · cost: $1.5874 · model: claude-opus-4-7

Copy link
Copy Markdown
Contributor

@aethersdr-agent aethersdr-agent Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks @chibondking — clean follow-up to #3175.

Confirmed against the diff:

  • currentAdaptiveFpsCap() returning 0 when the toggle is off correctly propagates through both activeCap > 0 (line 3423) and the lambda's cap > 0 (line 3432), so the two sendAdaptiveCapToPan() call sites in ensureOwnedPanadapter() are gated without per-site checks.
  • applyAdaptiveFrameRate() still calls fpsCapForState() directly and keeps its own AppSettings early-return at line 2806 — that's preserved as you noted.
  • AppSettings key spelling and "True"/"False" string comparison match the existing pattern at line 2806 and in ConnectionPanel.cpp:565/575. No QSettings drift.
  • Qt::UniqueConnection on the waterfallIdChanged lambda is the right call — PanadapterModel could in principle re-emit on a stream re-bind.
  • The comment about the "pan created healthy → network degrades before waterfallId arrives" ordering edge case is a useful breadcrumb; agreed it's better noted than chased here, since the next tier transition catches it.

LGTM.


🤖 aethersdr-agent · cost: $6.4489 · model: claude-opus-4-7

@ten9876 ten9876 merged commit 4416262 into aethersdr:main May 27, 2026
5 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants