fix(pnp): distinguish router read failure from no-internet (#1098) - #1132
Conversation
The PnP internet check collapsed two distinct outcomes into NoInternet: a genuine "no internet" (WAN read succeeds, Status != 'Up') and a read failure (USP GET returns empty / missing fields → WanStatus.fetch throws). The two are already separated by control flow — return false vs throw — but the catch block discarded that distinction, so "Try again" could never recover while GETs stayed empty, and the no-internet troubleshooter options (restart modem / enter ISP settings) were shown for a read failure where they are meaningless. Split the two paths: return false → NoInternet (unchanged); throw → new AdminReadFailure phase, carrying the ServiceError code/detail for diagnostics and rendered as a plain error card + retry on the entry view (no redirect to the no-internet hub). Both startPostLoginFlow and _checkInternet now branch on ServiceError, so a read failure at either the SystemInfo or WanStatus step lands in the same phase. AdminReadFailure fully replaces the former AdminError.
…1098) The classification fix landed AdminReadFailure at the notifier, but _checkInternet() has multiple callers and only the entry view rendered the new phase. The no-internet retry and the ISP-save (PPPoE / Static IP / DHCP) flows had no branch for it, so a read failure there would stall silently. Route every read-failure path back to the entry view (RoutePath.pnp). Because /pnp and the no-internet subtree are independent top-level route trees, go() mounts a fresh PnpEntryView whose initState re-runs startPostLoginFlow — an implicit retry that settles on the read-failure card only if it still fails. The ISP-save WRITE failure path is deliberately left on NoInternet + snackbar: _checkInternet does not rethrow, so a trailing check read-failure is handled inside it (AdminReadFailure), and only a genuine save-write failure reaches the saveIspWithProgress catch — the user should stay on the form to fix it. Also delete saveIspSettingsAndCheck (dead code, zero callers; superseded by saveIspWithProgress) and add a regression test for the save-succeeds-but-check- read-fails path.
Qodo reviews are paused for this user.Troubleshooting steps vary by plan Learn more → On a Teams plan? Using GitHub Enterprise Server, GitLab Self-Managed, or Bitbucket Data Center? |
AustinChangLinksys
left a comment
There was a problem hiding this comment.
🤖 Automated Review — Round 1 · b9c33cd..78b5ece (full)
Verdict: ✅ APPROVE — Core fix is correct and well-tested; four minor suggestions only.
| Conf. | Where | Issue (one-liner) | |
|---|---|---|---|
| 💡 | 🟡Med | pnp_notifier.dart:47-57 + :79-90 |
[both reviewers] Duplicated on ServiceError/catch pattern in two methods — minor DRY gap |
| 💡 | 🟡Med | pnp_notifier.dart:471-477 |
saveIspWithProgress outer catch maps save-write failure to NoInternet — intentional but undocumented |
| 💡 | 🟡Med | pnp_state.dart:AdminReadFailure |
code/detail both nullable with no assert; a fully-null instance is undiagnosable |
| 💡 | ⚪Low | pnp_notifier.dart (4 AdminReadFailure(detail:'$e') sites) |
detail stores raw exception string in state; future UI misuse could surface internal error text |
Confidence: 🟢High = code-verified · 🟡Med = located + reasoned, not fully confirmed · ⚪Low = speculative, please double-check.
Items marked [both reviewers] were independently flagged by two agents → higher confidence.
Notes on false-positive findings from sub-agents (evidence trail)
Two items initially flagged as Critical by Reviewer B were confirmed as false positives upon reading HEAD source:
pnp_entry_view.dart:63— switch arm already readsAdminReadFailure()(notAdminError), compiles correctly.pnp_notifier_test.dart:130+— all three new tests are complete with proper setup; oldAdminErrortest was correctly updated toAdminReadFailure.
One item initially flagged as Warning by Reviewer A was confirmed as false positive:
_checkInternet()on ServiceErrorscope —fetchCurrentSsid()atpnp_service.dart:547has an internalcatch (_) { return null; }that swallows all exceptions;ServiceErrorcannot propagate to the outer block, so theon ServiceErrorhandler only coverscheckInternetConnected()as intended.
💡 Suggestion Details
S1 — Duplicated catch pattern [both reviewers] (🟡Med)
pnp_notifier.dart:47-57 (startPostLoginFlow) and :79-90 (_checkInternet) each contain nearly identical on ServiceError + catch blocks differing only in the log tag:
} on ServiceError catch (e) {
logger.e('[PnP] ... read failure: $e (code=${e.code})');
state = state.copyWith(phase: AdminReadFailure(code: e.code, detail: '$e'));
} catch (e) {
logger.e('[PnP] ... unexpected error: $e');
state = state.copyWith(phase: AdminReadFailure(detail: '$e'));
}Consider extracting a _setReadFailure(String tag, Object e) helper to avoid future divergence.
S2 — saveIspWithProgress outer catch semantics (🟡Med)
pnp_notifier.dart:471-477: the outer catch fires only for saveIspSettings() write failures (because _checkInternet() handles its own errors internally and never re-throws). Mapping a save failure back to NoInternet + errorMessage is intentional — user sees the ISP form again to retry — but this intent is undocumented. A brief comment like // Write failure: stay on NoInternet so user can retry the save would prevent future maintainers from inadvertently "fixing" this into AdminReadFailure.
S3 — AdminReadFailure nullable fields, no assert (🟡Med)
pnp_state.dart:
class AdminReadFailure extends PnpPhase {
final int? code;
final String? detail;
const AdminReadFailure({this.code, this.detail});Both fields are nullable. A const AdminReadFailure() with no context is undiagnosable in logs. Consider:
const AdminReadFailure({this.code, this.detail})
: assert(code != null || detail != null,
'AdminReadFailure: at least one of code/detail must be set for diagnostics');S4 — detail stores raw exception string in state (⚪Low)
pnp_notifier.dart (four AdminReadFailure(detail: '$e') sites): ServiceError.toString() can include TR-181 paths or firmware error strings. The doc comment already says "for logging only" and pnp_entry_view.dart correctly uses loc(context).unableToGatherDeviceInfo (not detail). No current UI leakage. Suggest adding an explicit "MUST NOT surface in UI" note to the class-level doc or field doc.
✅ What looks good
- Core semantic distinction is correct: renaming
AdminError -> AdminReadFailureand adding dedicatedon ServiceErrorintercepts cleanly separates "router confirmed no internet" (returns false, no throw) from "read itself failed" (throws). Directly resolves #1098. saveIspSettingsAndCheckremoval is safe: confirmed no external callers in the codebase; dead code cleanly deleted.- All 5 ISP/troubleshooter views updated:
pnp_isp_settings_view,pnp_no_internet_view,pnp_pppoe_view,pnp_static_ip_view,pnp_entry_viewall handleAdminReadFailure -> context.go(RoutePath.pnp)— implicit retry is consistent. - Test coverage is solid: three new regression tests covering device-info read failure, WAN read failure (not collapsed into NoInternet), and ISP-save-success-but-check-read-failure. All properly structured and mocked.
- Architecture compliance:
ServiceErrorimport fromcore/errors/follows three-layer dependency rule.uspMutationLockProvider.withLock()correctly wraps the mutation insaveIspWithProgress. No autoDispose violations detected. - UI uses l10n fixed string:
loc(context).unableToGatherDeviceInfoinstead of surfacing raw error details.
Cross-reviewed by two independent agents (security+correctness / architecture+maintainability). Automated — please sanity-check before merge.
Fixes #1098
Background / Root cause
During PnP first-time setup, the app runs an internet check after login. In the reported session the device returned an empty
{}payload for every USP GET (396/396 empty), so the WAN read failed. The user saw a brief spinner, landed on the no-internet page, and "Try again" could never recover — even after the WAN cable was reconnected and the internet was genuinely back.The internet check conflated two distinct outcomes into a single
NoInternetstate. They are already separated by control flow — the code just discarded that distinction:WanStatus.fetchsucceeds, WANStatus != 'Up'→checkInternetConnected()returnsfalse(no throw).WanStatus.fetchthrows (codegen fault9998) → mapped to aServiceErrorand rethrown.PnpNotifier._checkInternet()had theelsebranch (path A) and thecatchblock (path B) both produceNoInternet. So a read failure — where we genuinely cannot tell the WAN state — was reported as the router confirming there is no internet, and "Try again" just looped.Affected paths
_checkInternet()is not only triggered by the entry view — it has multiple callers, each in a different view context. It also does not rethrow: it writes the resulting phase itself and returns.startPostLoginFlow()switch(phase)renders itretryInternetCheck()ref.listenonly; read failure → spinner ends, stucksaveIspWithProgress()AdminReadFailure→ stuck on saving / blanksaveIspWithProgress()(DHCP)_onDhcpTapmisses it → silently does nothingsaveIspSettingsAndCheck()The save-vs-read distinction inside
saveIspWithProgressTwo different failure points must not share a fate:
saveIspSettings()write fails (bad value, PPP instance fail) → not a read failure. Stay on the ISP form + errorMessage snackbar so the user can fix it._checkInternet()read-fails → real read failure →AdminReadFailure.Because
_checkInternet()doesn't rethrow, case 2 is handled inside it and never reaches thesaveIspWithProgresscatch. Only case 1 reaches that catch — so it staysNoInternet+ snackbar, unchanged.Solution
Introduce a dedicated
AdminReadFailurephase (replacing the formerAdminError, whose only job was an error card + retry — a strict superset, so no orphan is left), carrying theServiceErrorcode/detail for diagnostics.Provider
startPostLoginFlow()+_checkInternet()catch:on ServiceError→AdminReadFailure(covers a read failure at either theSystemInfoorWanStatusstep).saveIspWithProgress()catch: unchanged (NoInternet+ errorMessage — write-failure path).saveIspSettingsAndCheck().Views — every read-failure path converges on the entry view (
RoutePath.pnp): no-internet / PPPoE / Static IP listeners + DHCP_onDhcpTapeach route to it onAdminReadFailure.Why "navigate to entry" = implicit retry (important — non-obvious)
/pnp(PnpEntryView) and/pnpNoInternetConnection(+ its isp/pppoe/static-ip children) are two independent top-level route trees — the no-internet subtree is not nested under/pnp. The entry view reaches the no-internet subtree viacontext.go(...), which replaces the stack, so PnpEntryView is already unmounted by the time the user is there.Therefore
go(RoutePath.pnp)from paths #2/#3 is a cross-tree switch that mounts a fresh PnpEntryView, whoseinitStatere-runsstartPostLoginFlow(). SincepnpProvideris not autoDispose, the phase persists;startPostLoginFlowoverwrites the lingeringAdminReadFailurewithAdminCheckingInternetand re-checks.Net effect: back to entry → re-run the whole flow → only if it still fails does it settle on
AdminReadFailureand render the error card (no infinite loop). If the transient condition cleared (e.g. FW warm-up finished), it proceeds straight into the wizard. Cost: one redundant check (a few seconds), which is the intended retry.Expected behavior after the fix
Status != 'Up'NoInternetNoInternet(unchanged)NoInternet, retry loops foreverAdminReadFailure→ entry error card + retrySystemInfostep (reported session)AdminErrorAdminReadFailure(consistent)NoInternet+ snackbarNoInternet+ snackbar (unchanged — stay on form)Tests
AdminReadFailure, notNoInternet(regression for this bug).SystemInfofails →AdminReadFailure.saveIspWithProgresswhere save succeeds but trailing check read-fails →AdminReadFailure.NoInternet+ errorMessage; genuine no-internet staysNoInternet.No widget tests: the added navigation is a thin
if (phase is AdminReadFailure) go(pnp)branch, and this feature's test suite is provider/service/model only. Core logic is covered by the provider tests above.Scope note
The firmware-side trigger (why every USP GET returns empty after a successful login) still needs FW confirmation (
need-fw-confirm). This PR is the UI-resilience fix, which also hardens against any future transient read failure.