fix(diagnostics): honor Cancel mid-flow by guarding step boundaries (#1148) - #1175
Conversation
…1148) The full-diagnostic and internet-diagnostic runners only checked _cancelled once at the top, then ran ~10 sequential awaited steps with no interleaved guards. cancel() sets _cancelled=true but awaits the in-flight run future, so once past the entry check the flow ran every remaining step to completion before cancel could take effect — the user saw Cancel do nothing until the whole run finished. Add if (_cancelled) return; at each step boundary in both multi-step runners so cancellation short-circuits at the next step. Single-step per-flow runners already guard at entry. Add regression test asserting subsequent steps are not invoked after a mid-flow cancel (fails without the guards). Refs #1148
AustinChangLinksys
left a comment
There was a problem hiding this comment.
🤖 Automated Review — Round 1 · c45ee12..6140045 (full)
Verdict: 💬 COMMENT — Spec Critical: _runSharedSpeedTest uninterruptible; Cancel can block state-reset up to 3 min
Standards
| Conf. | Where | Issue (one-liner) | |
|---|---|---|---|
| 🟢High | unified_diagnostics_notifier.dart:104 |
startWithPreQualifier() missing _cancelled=false reset and _runFuture assignment; Cancel during WAN check can inadvertently restart the flow |
|
| 🟢High | unified_diagnostics_notifier.dart:623,670,699,803 |
4 single-step runners (_runDeviceIssues, _runWifiCoverage, _runMeshBackhaul, _runIntermittent) missing if (_cancelled) return; before _analyzeAndShowResults |
|
| 🟢High | unified_diagnostics_notifier.dart:858 |
_runSharedSpeedTest 3-min timeout with no _cancelled check; Cancel during speed test blocks cancel() caller up to 3 min |
|
| 🟡Med | unified_diagnostics_notifier.dart:266 |
goBack() unawaited tail: in-flight step completing after state-reset briefly overwrites cleared results |
|
| 🟡Med | unified_diagnostics_notifier.dart:16 |
AutoDisposeNotifier + unawaited scope-release: ref.read calls in goBack() async closure may throw after dispose |
|
| 💡 | 🟢High | unified_diagnostics_notifier_test.dart:802 |
Cancel regression covers internet flow only; _runFullDiagnosticFlow 10 new guards have no test |
| 💡 | 🟢High | unified_diagnostics_notifier.dart:623,670,699,803 |
4 single-step flows also lack cancel / back mid-flow tests |
| 💡 | 🟢High | unified_diagnostics_notifier.dart:327 |
[DuplicatedCode] 25 scattered cancel guards — consider _runStepIfActive() wrapper or add invariant doc comment |
| 💡 | 🟡Med | unified_diagnostics_notifier.dart:319 |
_ensureScope() call before Step 2 not guarded by _cancelled; minor asymmetry with other await points |
Confidence: 🟢High = code-verified · 🟡Med = located + reasoned, not fully confirmed · ⚪Low = speculative, please double-check.
[X] = 1 agent · [XX] = 2 agents flagged independently → higher confidence (Critical only).
Spec (#1148 — "Action should stop immediately")
| Conf. | Where | Issue (one-liner) | |
|---|---|---|---|
| 🔴 [X] | 🟢High | unified_diagnostics_notifier.dart:858–864 |
[Omission] Speed test in progress: _runSharedSpeedTest never checks _cancelled; cancel() blocks state-reset for up to 3 min — directly violates "stop immediately" |
| 🟡Med | unified_diagnostics_notifier.dart:377,553 |
[Partial omission] Individual ping awaits (seconds each) also uninterruptible mid-step; pragmatically minor vs spec intent | |
| 🟢High | unified_diagnostics_notifier.dart:205 |
[Wrong impl] cancel() resets state only after awaiting full in-flight future; UI stays non-idle during wait (vs. goBack() which resets immediately) |
|
| 🟢High | unified_diagnostics_notifier_test.dart:802 |
[Omission] No regression test for speed-test-in-progress cancel; worst-case scenario untested |
Confidence: 🟢High = code-verified · 🟡Med = located + reasoned, not fully confirmed · ⚪Low = speculative, please double-check.
[X] = 1 agent · [XX] = 2 agents flagged independently → higher confidence (Critical only).
🔴 Critical — Evidence Chain
[Spec][X] unified_diagnostics_notifier.dart:858–864 — Speed test uninterruptible during Cancel (High confidence)
1. Location: lib/page/unified_diagnostics/providers/unified_diagnostics_notifier.dart:831–864 (head version)
2. Code snippet (head version — _runSharedSpeedTest, lines 831–864):
void checkState() {
final speedState = ref.read(speedTestProvider).valueOrNull;
if (speedState == null) return;
if (speedState.step == SpeedTestStep.completed && speedState.result != null) {
if (!completer.isCompleted) completer.complete(speedState.result);
} else if (speedState.step == SpeedTestStep.error) {
if (!completer.isCompleted) completer.complete(null);
}
// _cancelled is NEVER checked here — no early-exit path
}
// Lines 857–864:
final result = await completer.future.timeout(
const Duration(minutes: 3),
onTimeout: () {
logger.w('[Diagnostics] Speed test timed out');
return null;
},
);3. Why this is a bug: cancel() at L206 does _cancelled = true then await inFlight. inFlight is _runFullDiagnosticFlow or _runInternetDiagnostics, which calls _runSharedSpeedTest. That call blocks on completer.future for up to 3 minutes — _cancelled is never observed inside checkState(), so the Completer never resolves early. Result: cancel()'s state = const UnifiedDiagnosticsState() (the idle reset) is delayed up to 3 minutes. From the user's perspective, pressing "Cancel Diagnostics" during a speed test has no visible effect — the same symptom as the original #1148 bug, now scoped to the speed-test step.
4. Trigger condition: User presses "Cancel Diagnostics" while _runSharedSpeedTest is actively awaiting the speed-test Completer (i.e., after all earlier step-boundary guards have been passed and the speed test has started).
5. Fix:
void checkState() {
// Early-exit if diagnostics was cancelled
if (_cancelled && !completer.isCompleted) {
completer.complete(null);
return;
}
// ... existing logic unchanged
}Also consider calling ref.read(speedTestProvider.notifier).cancel() to actually halt the underlying speed-test operation rather than merely abandoning the wait.
⚠️ Warning Details
W-1 [Standards — A+B, High]: startWithPreQualifier() missing _cancelled=false and _runFuture assignment
lib/page/unified_diagnostics/providers/unified_diagnostics_notifier.dart:104
runFullDiagnostic() (L84) and selectFlow() (L180) both reset _cancelled = false at entry. startWithPreQualifier() does neither. It also never assigns its Future to _runFuture. Concurrent cancel scenario:
startWithPreQualifier()starts;_runFutureisnull- User cancels →
cancel()sets_cancelled=true, finds_runFuture==null, immediately resets state to idle - In-flight
await svc.checkWanStatus()resolves; code reachesselectFlow(DiagnosticFlow.internet)at L129 selectFlowresets_cancelled=falseand launches_runInternetDiagnostics— silent restart after cancel
Fix: Add _cancelled = false; final f = _startPreQualifierImpl(); _runFuture = f; try { await f; } finally { if (identical(_runFuture, f)) _runFuture = null; } pattern matching the other entry points, and guard before selectFlow() with if (_cancelled) return;.
W-2 [Standards — A, High]: 4 single-step runners missing guard before _analyzeAndShowResults
lib/page/unified_diagnostics/providers/unified_diagnostics_notifier.dart:623, 670, 699, 803
_runDeviceIssuesDiagnostics, _runWifiCoverageDiagnostics, _runMeshBackhaulDiagnostics, _runIntermittentDiagnostics guard at entry but not before _analyzeAndShowResults. If goBack() fires during the final async operation, it immediately sets state to selectFlow/idle, but the in-flight step completes and calls _analyzeAndShowResults, overwriting state to showingResults — user sees the results page after pressing Back.
Fix: Add if (_cancelled) return; before each await _analyzeAndShowResults(results) in all 4 functions (same pattern already applied in _runFullDiagnosticFlow at diff +449 and _runInternetDiagnostics at diff +583).
W-3 [Standards — A, High]: _runSharedSpeedTest 3-min block
See Critical evidence above. From Standards axis: cancel() at L206–216 await inFlight then resets state. Speed test blocks this path for up to 3 min; scope not released during that window.
W-4 [Standards — A, Med]: goBack() unawaited tail briefly overwrites results
unified_diagnostics_notifier.dart:266
goBack() synchronously sets state.results = [] then fires unawaited async cleanup. In-flight step try block continues and executes state = state.copyWith(results: List.from(results)) one event-loop tick later, transiently populating results while step == selectFlow. Minor UX artifact (flicker) but inconsistent with intended clean-slate behavior.
W-5 [Standards — B, Med]: AutoDisposeNotifier + unawaited dispose safety
unified_diagnostics_notifier.dart:16
UnifiedDiagnosticsNotifier extends AutoDisposeNotifier. goBack() fires unawaited(() async { ... await _releaseScope(); }()) which internally calls ref.read(...). If the notifier disposes (e.g., user navigates away and Riverpod GC fires) before the unawaited closure runs, ref.read will throw StateError: ref.read was called on a disposed Ref. Guard with if (!mounted) return; at the top of the async closure.
✅ What Looks Good
- Guard placement semantically correct: all new
if (_cancelled) return;are placed beforestate = state.copyWith(step: ...), preventing spurious step-state updates after cancel. cancel()awaits in-flight future before releasing scope: prevents scope race with pending async ops.- Regression test technique:
Completer<PingResult>to hang gateway ping, thencancel(), then verifyverifyNever(() => mockService.pingDns(...))— correct async testing approach. - Both multi-step runners comprehensively guarded:
_runFullDiagnosticFlow(10 boundaries) and_runInternetDiagnostics(7 boundaries) fully covered including pre-_analyzeAndShowResults. selectFlow()andrunFullDiagnostic()reset_cancelled=false: clean flag state for each fresh run.flutter analyze/dart formatclean: confirmed in PR description; 24/24 tests pass.
Cross-reviewed by two independent agents (security+correctness / architecture+maintainability). Automated — please sanity-check before merge.
🔴 Adversarial re-review CONFIRMED this Critical [unified-diagnostics-notifier-dart-858-864]An independent read-only reviewer (different model) traced the finding to its sink and confirmed it is a real issue (not a false positive). An automated review-fix task ( Automated by gate.py confirm-lane. Merge / ready-for-review / closing remain manual (Austin). |
…st (#1148) cancel() awaited the in-flight run before resetting UI state, so pressing Cancel during DiagnosticStep.runningSpeedTest appeared ignored for up to the speed test's 3-minute timeout. Move the in-flight/scope teardown off the critical path via unawaited(...) (mirroring goBack()'s running case) and actively call the shared speed-test notifier's cancel() so its polling future stops promptly. Add a #1148 regression test covering cancel during the speed-test step.
🔧 Automated review-fix applied (L2)Addressed the confirmed code-review finding on this PR's branch ( Finding (quoted)
Fix
Future<void> cancel() async {
logger.d('[Diagnostics] Cancelled');
_cancelled = true;
unawaited(() async {
// Actively stop the shared speed test so its polling future observes the
// cancellation and completes promptly instead of lingering to timeout.
try {
await ref.read(speedTestProvider.notifier).cancel();
} catch (_) {}
final inFlight = _runFuture;
if (inFlight != null) {
try {
await inFlight;
} catch (_) {}
}
await _releaseScope();
}());
state = const UnifiedDiagnosticsState(); // now runs synchronously
}Two coordinated changes:
Why this is correctThe Cancel press now takes effect immediately (state resets on the current turn) while cleanup runs off the critical path, and the underlying speed test is told to stop instead of being abandoned to its timeout — exactly the non-blocking behavior the Verification
|
AustinChangLinksys
left a comment
There was a problem hiding this comment.
🤖 Automated Review — Round 2 · 6140045..89955b4 (incremental)
Verdict: ✅ APPROVE (💬 COMMENT — GitHub self-approve restriction on dispatch-chain PR) — 0 Critical; Round 1 Critical resolved. 5 Standards Warnings (3 double-audited → review-fix cards queued; 2 single-audit/pre-existing → comment only); 1 Spec Warning (UI-transparent).
Standards
| Conf. | Where | Issue (one-liner) | |
|---|---|---|---|
| 🟢High | unified_diagnostics_notifier.dart:650,697,726,830 |
4 single-step runners missing tail if (_cancelled) return; before _analyzeAndShowResults — cancel state overwritten by late in-flight results (Cont. W-2) |
|
| 🟢High | unified_diagnostics_notifier.dart:213–216 |
cancel() unawaited: ref.read(speedTestProvider.notifier) in async closure; if notifier disposed before closure runs, speed-test cancel silently skipped via catch(_){} (New) |
|
| 🟢High | unified_diagnostics_notifier.dart:104 |
startWithPreQualifier() still missing _cancelled = false reset — stale flag from prior cancel silently aborts the very next pre-qualifier run (Cont. W-1) |
|
| 🟢High | unified_diagnostics_notifier.dart:208–226 |
cancel() declared Future<void> but resolves before _releaseScope()/in-flight drain — callers that await cancel() get a false "fully done" signal (New, single-audit) |
|
| 🟡Med | unified_diagnostics_notifier.dart:266–274 |
goBack() unawaited tail: in-flight step can overwrite canceled results; ref.read post-dispose risk in running closure (Cont. W-4/W-5, pre-existing) |
|
| 💡 | 🟢High | unified_diagnostics_notifier_test.dart:764–804 |
Pre-existing test cancel awaits the in-flight run + verify(release).called(1) assert old synchronous contract — stale after Round 2 redesign (New) |
| 💡 | 🟡Med | unified_diagnostics_notifier.dart:211–224,266–274 |
[DuplicatedCode] identical unawaited teardown pattern in cancel() and goBack(); extracting _teardownAsync() would also expose goBack()'s missing speed-test cancel (Cont.) |
| 💡 | 🟢High | unified_diagnostics_notifier.dart:327 |
[DuplicatedCode] 25 scattered if (_cancelled) return; guards — consider _runStepIfActive() helper or invariant doc comment (Cont. from R1) |
Confidence: 🟢High = code-verified · 🟡Med = located + reasoned, not fully confirmed · ⚪Low = speculative, please double-check.
[X] = 1 agent · [XX] = 2 agents flagged independently → higher confidence (Critical only).
Spec (#1148 — "Action should stop immediately")
| Conf. | Where | Issue (one-liner) | |
|---|---|---|---|
| 🟡Med | spec: "stop immediately" → unified_diagnostics_notifier.dart:519,534,563 |
Ping awaits remain uninterruptible mid-step; spec satisfied for UI reset (state resets synchronously) but background ping calls linger a few seconds (Cont. R1) |
|
| 💡 | ⚪Low | N/A → unified_diagnostics_notifier.dart:211–224 |
Scope-creep informational: unawaited teardown closure uses blanket catch(_){} — any unexpected error silently swallowed (New) |
Confidence: 🟢High = code-verified · 🟡Med = located + reasoned, not fully confirmed · ⚪Low = speculative, please double-check.
[X] = 1 agent · [XX] = 2 agents flagged independently → higher confidence (Critical only).
🔴 Critical — Evidence Chain
No Critical findings this round. Round 1 Critical (_runSharedSpeedTest uninterruptible — 3-min block on cancel()) fully resolved. See ✅ section below.
⚠️ Warning Details
W-1 [Standards A+B, 🟢High — double-audited] unified_diagnostics_notifier.dart:650,697,726,830 — 4 single-step runners missing tail cancel guard
B2 Verdict: review-fix card queued.
_runDeviceIssuesDiagnostics (L650), _runWifiCoverageDiagnostics (L697), _runMeshBackhaulDiagnostics (L726), _runIntermittentDiagnostics (L830) each have an entry-point guard but no mid-body guard before await _analyzeAndShowResults(results).
Scenario: user presses Cancel while the single await (e.g. getDeviceScores()) is mid-flight. _cancelled becomes true during the await. The try/catch block completes, execution reaches _analyzeAndShowResults unconditionally, and it writes step: analyzing → step: showingResults — overwriting the idle state that cancel() already committed synchronously at L225. User sees results page after pressing Cancel.
Compare: _runInternetDiagnostics (L596) and _runFullDiagnosticFlow (L462) both guard correctly:
if (_cancelled) return;
await _analyzeAndShowResults(results);Fix: Add if (_cancelled) return; before await _analyzeAndShowResults(results) in all four single-step runners.
W-2 [Standards A+B, 🟢High — double-audited] unified_diagnostics_notifier.dart:213–216 — cancel() unawaited closure: ref.read after possible dispose
B2 Verdict: review-fix card queued.
unawaited(() async {
try {
await ref.read(speedTestProvider.notifier).cancel(); // ← line 215
} catch (_) {} // ← swallows ALL errors here
final inFlight = _runFuture; // ← still reached (scope NOT leaked in normal path)
if (inFlight != null) { try { await inFlight; } catch (_) {} }
await _releaseScope(); // ← still reached
}());ref.read(speedTestProvider.notifier) runs inside an unawaited closure. If the AutoDisposeNotifier is disposed (e.g., route popped immediately after Cancel tap) before this closure runs, ref.read throws StateError. The catch (_) {} silently swallows it — speedTestProvider.notifier.cancel() is never called. The speed test stops eventually via checkState()'s _cancelled guard, but not as promptly as intended. Note: _releaseScope() is outside the inner try/catch and still runs in normal paths, so scope is not leaked here; the risk is specifically the speed-test active-cancel being silently skipped.
Fix: Capture the notifier reference synchronously before entering the async closure:
void cancel() { // consider void to match goBack()'s fire-and-forget contract
_cancelled = true;
final speedTestNotifier = ref.read(speedTestProvider.notifier); // sync capture
unawaited(() async {
try { await speedTestNotifier.cancel(); } catch (_) {}
final inFlight = _runFuture;
if (inFlight != null) { try { await inFlight; } catch (_) {} }
await _releaseScope();
}());
state = const UnifiedDiagnosticsState();
}W-3 [Standards A+B, 🟢High — double-audited] unified_diagnostics_notifier.dart:104 — startWithPreQualifier() missing _cancelled = false
B2 Verdict: review-fix card queued.
Future<void> startWithPreQualifier() async {
// ← NO: _cancelled = false;
logger.i('[Diagnostics] Starting with pre-qualifier');
state = const UnifiedDiagnosticsState(step: DiagnosticStep.preQualifying);
...
final wan = await svc.checkWanStatus(); // yield point — _cancelled may be stale truerunFullDiagnostic() (L84) and selectFlow() (L180) both reset _cancelled = false at entry. startWithPreQualifier() does not. Scenario: user cancels run A → immediately taps Start (pre-qualifier path). _cancelled is still true. On WAN-check code paths that do not transition through selectFlow() (e.g., the WAN-ok / internet-ping branch at L134–167), _cancelled remains true for the entire execution. Any downstream if (_cancelled) return; guard (e.g., inside the next flow) fires on the new run, silently aborting it with no user feedback.
_runFuture is also never assigned, so cancel() has no handle on the pre-qualifier future; _releaseScope() in the background closure will still run after _runFuture == null check — but the in-flight network calls cannot be tracked or awaited.
Fix: Add _cancelled = false; as the first statement in startWithPreQualifier(). Optionally assign _runFuture following the same pattern as selectFlow().
W-4 [Standards B, 🟢High — single-audit, tech-debt] unified_diagnostics_notifier.dart:208–226 — cancel() Future<void> resolves before teardown
B2 Verdict: single-audit, tech-debt — comment only. (Austin: verify if await cancel() callers exist that expect teardown to be complete.)
cancel() is async and returns Future<void>. In Dart convention, a caller that await cancel() expects all cancellation work committed when the future resolves. After Round 2, _releaseScope() and await inFlight are in an unawaited closure — they complete after the Future<void> resolves. A sequence like await notifier.cancel(); await notifier.selectFlow(...) may encounter a still-live scope from the prior run. The existing test at line 764 (cancel awaits the in-flight run and releases scope) passes by microtask-ordering coincidence on the test event loop but documents the old (now incorrect) contract.
Consider changing the signature to void cancel() (matching goBack()) to make the fire-and-forget semantic explicit to callers.
W-5 [Standards A, 🟡Med — single-audit, pre-existing] unified_diagnostics_notifier.dart:266–274 — goBack() unawaited tail
B2 Verdict: single-audit, pre-existing from Round 1 — comment only.
goBack() fires unawaited(() async { await inFlight; await _releaseScope(); }()). If an in-flight step completes during the unawaited window and calls state = state.copyWith(...), it transiently overwrites the clean state that goBack() already set (UX flicker). ref.read calls in the in-flight chain may also throw if the notifier disposes before the closure runs. Additionally, unlike cancel(), goBack() does not call speedTestProvider.notifier.cancel(), so back-navigation during speed test still relies on the _cancelled flag alone (slower stop). Pre-existing; not introduced by Round 2.
WS-1 [Spec C, 🟡Med] unified_diagnostics_notifier.dart:519,534,563 — Ping awaits uninterruptible mid-step
Spec: "Expected result: Action should stop immediately."
Individual await svc.pingGateway/Dns/Internet() calls (each with repeatCount iterations) are not cancellation-aware internally. _cancelled is checked at step boundaries only. After Round 2, the UI state resets synchronously (spec satisfied from the user's perspective: Cancel button immediately shows idle UI). However, the background teardown closure continues running these ping awaits for up to several seconds before _releaseScope() fires. This is a background resource/liveness concern rather than a visible spec violation — the user sees immediate cancellation.
Optional hardening: thread cancellation into the ping service calls, or poll _cancelled between repeatCount iterations inside the service layer.
✅ What Looks Good
- Round 1 Critical fully resolved (High confidence):
checkState()now has an earlyif (_cancelled)guard (L861–866) that immediately completes the Completer withnull. Dual-mechanism: flag-based early exit in polling callback + activespeedTestProvider.notifier.cancel()call to halt the underlying speed-test operation. cancel()state reset now synchronous:state = const UnifiedDiagnosticsState()executes at L225 directly after_cancelled = true, before anyawait. UI resets immediately on Cancel tap regardless of in-flight teardown duration. This directly satisfies Issue #1148 "stop immediately."- Regression test correct and sufficient:
_HangingSpeedTestNotifierapproach blocks the speed test at the actualrunSpeedTestawait (simulating real-world hang). Test verifies bothstate.step == DiagnosticStep.idle(immediate state reset) andhangingSpeedTest.cancelCalled == true(active cancel). PR description confirms the test fails without the fix. - Multi-step runners comprehensively guarded:
_runFullDiagnosticFlow(10 step-boundaries) and_runInternetDiagnostics(7 boundaries) were fully guarded in Round 1 and remain correct in this diff. selectFlow()andrunFullDiagnostic()correctly reset_cancelled = false: ensures clean flag state for each new diagnostic run._runSharedSpeedTest's speed test subscription cleanup: callingref.read(speedTestProvider.notifier).cancel()rather than just abandoning the wait is the right semantic — it actually halts the underlying speed-test provider state machine, not just the diagnostics listener.
Cross-reviewed by two independent agents (security+correctness / architecture+maintainability). Automated — please sanity-check before merge.
…estart races (#1148) Review-fix round 2 (PR #1175): - RF-1: add `if (_cancelled) return;` before the tail _analyzeAndShowResults in the 4 single-step runners (device issues, wifi coverage, mesh backhaul, intermittent) so a mid-await cancel no longer overwrites the idle state. - RF-2: capture speedTestProvider.notifier synchronously in cancel() before the unawaited async gap, avoiding a StateError if the AutoDisposeNotifier is disposed before the deferred ref.read runs (cancellation now reliably reaches the speed test). - RF-3: reset _cancelled = false at the start of startWithPreQualifier() so a stale cancellation from a prior run no longer silently aborts a new run.
🔧 Review-fix applied (Round 2)Pushed RF-1 — 4 single-step runners missing tail
|
AustinChangLinksys
left a comment
There was a problem hiding this comment.
🤖 Automated Review — Round 3 · 89955b4..e038488 (incremental)
Verdict: ✅ APPROVE (💬 COMMENT — GitHub self-approve restriction) — 0 Critical; RF-1/RF-2/RF-3 all resolved. 2 Standards Warnings (single-audit / pre-existing → comment only, no cards); Spec axis clear.
Standards
| Conf. | Where | Issue (one-liner) | |
|---|---|---|---|
| 🟡Med | unified_diagnostics_notifier.dart:130,181 |
[pre-existing] startWithPreQualifier WAN-down auto-route: selectFlow() at L181 resets _cancelled = false, can override a concurrent cancel() during WAN-check async gap |
|
| 🟢High | unified_diagnostics_notifier_test.dart |
Test coverage gap: startWithPreQualifier() has zero test coverage; RF-1 tail guards for 4 single-step runners also untested for cancellation (single-audit) |
|
| 💡 | 🟡Med | unified_diagnostics_notifier.dart:105 |
No inline doc explaining the _cancelled = false invariant at startWithPreQualifier() entry (all three entry-points reset but none documents the contract) |
| 💡 | ⚪Low | unified_diagnostics_notifier.dart:222 |
speedTestNotifier.cancel() post-dispose behavior untested; catch (_) {} silently swallows any error — acceptable but warrants a note or test |
Confidence: 🟢High = code-verified · 🟡Med = located + reasoned, not fully confirmed · ⚪Low = speculative, please double-check.
[X] = 1 agent · [XX] = 2 agents flagged independently → higher confidence (Critical only).
Spec (#1148 — "Action should stop immediately")
| Conf. | Where | Issue (one-liner) | |
|---|---|---|---|
| 💡 | 🟡Med | spec: "stop immediately" → unified_diagnostics_notifier.dart:519,534,563 |
WS-1 (Cont. R1/R2): pingGateway/Dns/Internet awaits uninterruptible mid-step; UI resets synchronously (spec satisfied), background pings linger a few seconds — optional hardening only |
Confidence: 🟢High = code-verified · 🟡Med = located + reasoned, not fully confirmed · ⚪Low = speculative, please double-check.
[X] = 1 agent · [XX] = 2 agents flagged independently → higher confidence (Critical only).
🔴 Critical — Evidence Chain
No Critical findings this round. RF-1/RF-2/RF-3 all verified correct against HEAD source; no new critical issues introduced.
⚠️ Warning Details
W-6 [Standards A, 🟡Med — single-audit, pre-existing] unified_diagnostics_notifier.dart:130,181 — startWithPreQualifier WAN-down path: selectFlow() resets _cancelled
B2 Verdict: pre-existing — comment only.
Scenario: user taps Start → startWithPreQualifier() → _cancelled = false (RF-3) → await svc.checkWanStatus() (async gap) → user taps Cancel → _cancelled = true, UI reset to idle → WAN check returns WAN-down → code at L130 calls await selectFlow(DiagnosticFlow.internet) → selectFlow() at L181 resets _cancelled = false → internet diagnostics run to completion despite Cancel.
This race condition pre-dates RF-3: selectFlow()'s reset at L181 was always there; RF-3 only adds the reset at the top for a different scenario (stale cancel from a prior run). Not introduced by this diff.
Fix: Add if (_cancelled) return; before await selectFlow(...) calls within startWithPreQualifier() to check the flag after the WAN-check async gap.
WB-1 [Standards B, 🟢High — single-audit] unified_diagnostics_notifier_test.dart — Test coverage gap for RF-1/RF-2/RF-3 paths
B2 Verdict: single-audit — comment only.
startWithPreQualifier() has zero test coverage (confirmed by grep on the 906-line test file). Additionally:
- RF-1: The 4 new tail guards (
_runDeviceIssuesDiagnostics,_runWifiCoverageDiagnostics,_runMeshBackhaulDiagnostics,_runIntermittentDiagnostics) have no cancellation test — existing cancel tests only cover theDiagnosticFlow.internetpath. - RF-2: No test exercises the sync-capture fix; the mock speed test completes instantly, so the race window between
cancel()and notifier disposal is not covered. - RF-3: No test for the "cancel → startWithPreQualifier() → should complete normally" sequence.
Suggested additions (follow-up PR or here): (1) startWithPreQualifier() happy-path test; (2) cancel-during-preQualifier test; (3) per-flow cancellation tests for the 4 single-step runners.
✅ What Looks Good
- RF-1 fully resolved (🟢High):
if (_cancelled) return;correctly inserted beforeawait _analyzeAndShowResults(results)in all 4 single-step runners:_runDeviceIssuesDiagnostics(L657),_runWifiCoverageDiagnostics(L705),_runMeshBackhaulDiagnostics(L735),_runIntermittentDiagnostics(L840). Matches the pattern already established in_runInternetDiagnostics/_runFullDiagnosticFlow. - RF-2 fully resolved (🟢High):
speedTestProvider.notifierreference captured synchronously at L217, before theunawaitedasync gap. GuaranteesspeedTestNotifier.cancel()is invoked on the live notifier regardless of subsequent disposal. Correct and minimal fix. - RF-3 fully resolved (🟢High):
_cancelled = falseadded at L105 as the first statement instartWithPreQualifier(), consistent withrunFullDiagnostic()(L84) andselectFlow()(L181). Stale cancellation from a prior run no longer silently aborts a new pre-qualifier run. - 25/25 tests pass: Confirmed via author commit comment;
dart formatclean,flutter analyzeno issues. - Issue #1148 core requirement met: All diagnostic runner entry-paths now check
_cancelledat step boundaries;cancel()reliably reaches the speed test; stale flag cleared on restart. The "stop immediately" requirement is satisfied for all current flows.
Cross-reviewed by three independent agents (security+correctness / architecture+maintainability / spec-compliance). Automated — please sanity-check before merge.
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? |
Resolve conflicts in the unified diagnostics notifier: dev-2.7.0 removed the Speed Test step app-wide (blocked by FW support, #857) while this branch added mid-flow cancellation guards (#1148). Both flows' Step 5 speed-test blocks and the _runSharedSpeedTest helper are dropped per dev-2.7.0. Follow-on fixes required by the merge: - cancel(): drop the now-dangling speedTestProvider.notifier reference (its import was removed with the speed-test teardown); the #1148 core fix (synchronous state reset + off-critical-path teardown + step- boundary _cancelled guards) is preserved. - test: remove the now-unreachable "cancel during speed-test step" case and its unused _HangingSpeedTestNotifier; the ping-based #1148 regression test still guards immediate cancellation. Verified: dart format clean, flutter analyze clean on changed files, full functional suite 3333 passed / 0 failed. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
AustinChangLinksys
left a comment
There was a problem hiding this comment.
🤖 Automated Review — Round 4 · e038488..1e2afd6 (incremental)
Verdict: ✅ APPROVE (💬 COMMENT — GitHub self-approve restriction) — 0 Critical after synthesis; 2 new Standards Warnings (dead test imports + documented edge-case gap); Spec axis clear.
Standards
| Conf. | Where | Issue (one-liner) | |
|---|---|---|---|
| 🟢High | test/unified_diagnostics_notifier_test.dart:11-12, 106, 932 |
Dead code: speed-test imports, speedTestProvider.overrideWith, and _MockSpeedTestNotifier remain after notifier no longer uses speedTestProvider | |
| 🟡Med | device_filter_provider.dart:237-244 |
dataElementsId==null node: label falls back to n.displayName (model name) — #1157 fix silently does not apply to these nodes; no test covers this edge | |
| 🟡Med | unified_diagnostics_notifier.dart:131 (pre-existing) |
startWithPreQualifier WAN-down path: selectFlow() at L182 resets _cancelled=false, can override a concurrent cancel() during async gap — not introduced by this diff | |
| 🟢High | unified_diagnostics_notifier_test.dart (pre-existing) |
startWithPreQualifier() still has zero test coverage; RF-1 tail guards for 4 single-step runners also untested for cancellation | |
| 💡 | 🟡Med | unified_diagnostics_notifier.dart:9-11 |
Speed-test imports commented out rather than deleted — add TODO(#857) comment to prevent accidental removal |
Confidence: 🟢High = code-verified · 🟡Med = located + reasoned, not fully confirmed · ⚪Low = speculative, please double-check.
[X] = 1 agent · [XX] = 2 agents flagged independently → higher confidence (Critical only).
Spec
| Conf. | Where | Issue (one-liner) | |
|---|---|---|---|
| ✅ | 🟢High | unified_diagnostics_notifier.dart:212+303-781 |
#1148: _cancelled=true + unawaited UI reset + 30+ step-boundary guards all present at HEAD — Cancel now short-circuits immediately |
| ✅ | 🟢High | device_filter_provider.dart:237-249 |
#1157: labelByDataElementsId resolves friendlyName before model fallback; view uses n.label throughout |
| ✅ | 🟢High | usp_menu_view.dart, route_usp_dashboard.dart, router_provider.dart, notifier.dart |
#857: all Speed Test entry points (menu, route, diagnostics steps) disabled at HEAD |
No linked spec gap detected this round.
🔴 Critical — Evidence Chain
No Critical findings this round. Both subagents Critical candidates were false positives upon Synthesizer verification against the actual merge-commit HEAD (1e2afd6):
- Reviewer B C-1 ("_cancelled dead code + cancel() blocks UI"): Reviewer read stale local branch (pr-1167-review), not the actual PR HEAD. At 1e2afd6, cancel() correctly uses unawaited() + synchronous state reset, and 30+ if (_cancelled) return; guards are present throughout all flow methods. Not a bug.
- Reviewer C C-1/C-2/C-3 ("#1157 missing / #857 not disabled"): Reviewer read e038488 (pre-merge tip of gate/fix-1148), not the merge-commit HEAD 1e2afd6. The merge commit correctly incorporates NodeFilterOption (#1157) and Speed Test disable (#857). Not a bug.
⚠️ Warning Details
W-NEW-1 [Standards A, 🟢High] test/unified_diagnostics_notifier_test.dart:11-12, 106, 932 — Dead speed-test imports and stub
B2 Verdict: Warning — single-audit, comment only.
Production notifier at HEAD (1e2afd6) has zero references to speedTestProvider. However, the test file retains:
- L11-12: imports for speed_test_state.dart and speed_test_notifier.dart
- L106: speedTestProvider.overrideWith(() => _MockSpeedTestNotifier()) in createContainer()
- L932-965: the full _MockSpeedTestNotifier class
The override is currently harmless, but if speed_test_notifier.dart is later deleted (natural conclusion of #857), this test file will break at compile time with no obvious connection to the feature removal. Recommend follow-up cleanup.
Fix: Remove the three speed-test dead-code blocks from the test file.
W-NEW-2 [Standards A, 🟡Med] device_filter_provider.dart:237-244 — dataElementsId==null node: #1157 fix silently non-applicable
B2 Verdict: Warning — single-audit, comment only.
final labelByDataElementsId = {
for (final n in data.nodes)
if (n.dataElementsId != null) n.dataElementsId!: n.displayName,
};
final nodeOptions = data.meshTopology.nodes
.map((n) => NodeFilterOption(
id: n.deviceId,
label: labelByDataElementsId[n.deviceId] ?? n.displayName,
))
.toList();For a meshTopology node whose deviceId does not match any data.nodes[*].dataElementsId, the lookup misses and falls back to n.displayName from the meshTopology NodeEntity. That entity has no friendlyName data — displayName falls back to model. The chip still shows the model name for these nodes, silently bypassing the #1157 fix.
Fix (follow-up): Add a test case or doc comment acknowledging this limitation.
W-PREV-1 [Standards A, 🟡Med — pre-existing] unified_diagnostics_notifier.dart:131 — startWithPreQualifier WAN-down race
B2 Verdict: pre-existing — comment only (carried from Round 3).
Race: user taps Start → startWithPreQualifier() → await checkWanStatus() → user taps Cancel (_cancelled=true) → WAN-down path calls await selectFlow(DiagnosticFlow.internet) → selectFlow() L182 resets _cancelled=false → internet diagnostics run to completion despite Cancel. Not introduced by this diff.
Fix (follow-up): Add if (_cancelled) return; before await selectFlow(...) call within startWithPreQualifier().
W-PREV-2 [Standards A+B, 🟢High — pre-existing] unified_diagnostics_notifier_test.dart — Test coverage gap
B2 Verdict: pre-existing — comment only (carried from Round 3).
startWithPreQualifier() has zero test coverage. The 4 single-step runner tail guards also have no cancellation tests.
✅ What Looks Good
- #1148 fully resolved (🟢High): cancel() uses unawaited() for cleanup, immediately resets state. All 30+ if (_cancelled) return; step-boundary guards present throughout all runners.
- #857 Speed Test disable complete (🟢High): Menu entry, route, view import, notifier step blocks, helpers, recommendations case all removed. Diagnostics view step counts corrected (internet 7->6, full 10->9). Tests updated.
- #1157 node displayName fix clean (🟢High): NodeFilterOption {id, label} ViewModel correctly separates selection key from display label. Provider resolves once, view stays ignorant of name derivation. Two tests cover happy path and model-fallback.
- Merge conflict resolution correct (🟢High): startWithPreQualifier WAN-path correctly does not call into removed speed test. cancel() speedTestProvider reference cleanly removed.
- Round 3 RF-1/RF-2/RF-3: All three remain correctly resolved.
Cross-reviewed by three independent agents (security+correctness / architecture+maintainability / spec-compliance). Automated — please sanity-check before merge.
PeterJhongLinksys
left a comment
There was a problem hiding this comment.
🤖 Automated Review — Round 1 · 557d59c..1e2afd6 (full)
Verdict: 💬 Comment — The step-boundary guards are the right idea, but switching cancel() to a fire-and-forget teardown introduces an async re-entrancy hazard that deserves a human decision before merge.
| Conf. | Where | Issue (one-liner) | |
|---|---|---|---|
| 🔴 [X] | 🟡 | unified_diagnostics_notifier.dart:210-223 |
Fire-and-forget teardown + shared _cancelled reset by the next run lets an old runner revive, race on state, and can make _releaseScope() free the NEW run's scope. |
| 🟢 | unified_diagnostics_notifier.dart:498 (& sibling flows) |
A step already in-flight when cancel() fires still writes state.copyWith(results:...) after the state was reset to idle → idle state polluted with leftover results. |
|
| 🟢 | unified_diagnostics_notifier_test.dart:813-863 |
New #1148 test asserts only step == idle; it never asserts results is empty and never covers the cancel-then-restart re-entrancy path — so the Critical scenario is unguarded. |
|
| 🟡 | unified_diagnostics_notifier.dart:213-221 & 273-281 |
cancel() and goBack()'s running case duplicate the same fire-and-forget teardown block verbatim; the ordering invariant must be maintained in two places (same latent bug lives in both). |
|
| ⚪ | unified_diagnostics_notifier.dart:210 |
cancel() returns Future<void> but the real teardown is unawaited(...), so await cancel() completes before the scope is released — misleading contract for callers. |
|
| 💡 | 🟡 | unified_diagnostics_notifier.dart (~20 sites) |
[Shotgun Surgery] ~20 repeated if (_cancelled) return; guards across 6 flow methods; each new step must remember to re-add one. Consider a _step(...) wrapper. |
| 💡 | 🟡 | unified_diagnostics_notifier_test.dart |
Only the internet flow's first guard is covered; add a "cancel then restart runs full flow" test to lock the _cancelled reset behavior. |
| 💡 | ⚪ | unified_diagnostics_notifier.dart:211 |
cancel() logs at logger.d; other lifecycle events use logger.i. Cancel is a notable user action — consider i. |
Conf.: 🟢 High (read head code, evidence attached) · 🟡 Med (file:line + rationale, not fully proven) · ⚪ Low (speculative). [X] = raised by 1 agent · [XX] = both agents.
🔴 Critical — evidence chain
Location: cancel() :210-223, reset at startWithPreQualifier() :105-106, boundary guard e.g. :491-506, _releaseScope() :71-80.
Code:
// :210 cancel()
Future<void> cancel() async {
_cancelled = true;
unawaited(() async { // teardown no longer awaited
final inFlight = _runFuture;
if (inFlight != null) { try { await inFlight; } catch (_) {} }
await _releaseScope(); // reads whatever _scope is NOW
}());
state = const UnifiedDiagnosticsState(); // returns immediately (idle)
}
// :106
Future<void> startWithPreQualifier() async { _cancelled = false; ... }
// :491 runner boundary
if (_cancelled) return; // check
state = state.copyWith(step: pingGateway);
final ping = await svc.pingGateway(); // old runner parked here
...
state = state.copyWith(results: ...); // LATE WRITE after resume
if (_cancelled) return; // now false again → does NOT bailWhy it breaks (timeline):
- User cancels while
svc.pingGateway()is in-flight →_cancelled=true, teardown queued (await inFlightwaits for old runner),state=idlereturned; UI resets. - User immediately restarts →
startWithPreQualifier()sets_cancelled=falseand may acquire a new_scope. - Old ping resolves; old runner resumes, writes results, and its next
if (_cancelled) returnsees false → keeps running every subsequent step, racing the new run on the samestate. - Because the old runner never bailed, the queued
await inFlightonly completes after it finishes, then_releaseScope()reads_scope= the new run's scope and releases it → new diagnostics fail.
Trigger: cancel a running flow, then start a new one before the in-flight step resolves (a normal user action; goBack()'s running case shares the same pattern).
Suggested fix: replace the shared bool _cancelled with a monotonically-increasing run generation/epoch token captured per run (if (myGen != _runGeneration) return), and have _releaseScope() compare scope identity before releasing/nulling. This also fixes goBack() and the late-write Warning at once.
⚠️ Warnings
:498late write: even without a restart, the in-flight step completes aftercancel()set idle and executesstate = state.copyWith(results: ...);copyWithkeepsstep: step ?? this.step(idle), yieldingstep=idlewith a stray result. Guard the write itself, not only the boundary before theawait.- Test blind spot
:813-863: assertstate.resultsis empty after cancel, and add a cancel-then-restart case. - Duplicated teardown
:213-221/:273-281: extract a single private helper. cancel()Future semantics: either truly await the teardown or make itvoidand document fire-and-forget.
✅ What looks good
- No security issues; no hardcoded secrets/tokens.
- USP error handling untouched — all
catch (e)are untyped (verified noon Exception catch), correctly catching raw-String USP errors; new guards sit outside thetryblocks. - Adds a regression test for #1148 and the step-boundary guard approach is sound.
- No layering violations (Provider layer, no
generated/imports, no reverse deps); Art. IV/XIII not touched by this increment.
Cross-reviewed by independent agents (security+correctness / architecture+maintainability). Automated — please sanity-check before merge.
| } | ||
| await _releaseScope(); | ||
| }()); | ||
| state = const UnifiedDiagnosticsState(); |
There was a problem hiding this comment.
🔴 Critical (🟡 Med): fire-and-forget teardown here + shared _cancelled being reset to false by the next startWithPreQualifier() lets an old runner revive and race the new run on state, and can make the queued _releaseScope() free the NEW run's scope. Consider a per-run generation token instead of a shared bool, and compare scope identity before releasing. See the Critical evidence-chain section for the full timeline.
🔴 Adversarial re-review CONFIRMED this Critical [unified-diagnostics-notifier-dart-210-223]An independent read-only reviewer (different model) traced the finding to its sink and confirmed it is a real issue (not a false positive). An automated review-fix task ( Automated by gate.py confirm-lane. Merge / ready-for-review / closing remain manual (Austin). |
…token (#1148) Replace the shared _cancelled bool with a monotonic _generation token so a runner suspended on a long await cannot revive and clobber a newer run. - Each run entry (runFullDiagnostic/startWithPreQualifier/selectFlow) claims a fresh generation; all post-await state writes go through _publish(gen,..) which no-ops if the generation has moved on. - Step guards changed from 'if (_cancelled) return' to 'if (!_isCurrent(gen))'. - cancel()/goBack() bump the generation and capture the scope they own, so a subsequent run's _ensureScope acquires a fresh scope instead of having the new run's SSE subscription released out from under it. - Removed now-unused _releaseScope helper. - Add regression test that fails on the old shared-flag code (revived Run A with WAN Down clobbers Run B's WAN Up) and passes with the generation guard.
🔧 Review-fix applied — cancel→restart concurrency racePushed Original Warning (quoted)
Fix
Replaced the shared int _generation = 0;
bool _isCurrent(int gen) => _generation == gen;
void _publish(int gen, UnifiedDiagnosticsState next) {
if (_generation != gen) return; // stale runner → write is a no-op
state = next;
}
++_generation;
final scope = _scope;
_scope = null; // next run gets its own scope
unawaited(() async { await inFlight?; await scope?.release(); }());
Why this is correct: run identity is now explicit and monotonic. A runner captures its generation at entry; after any Verification
Scope kept to exactly the finding; no refactors beyond removing the dead |
AustinChangLinksys
left a comment
There was a problem hiding this comment.
🤖 Automated Review — Round 5 · 1e2afd6..37d2889 (incremental)
Verdict: ✅ APPROVE (💬 COMMENT — GitHub self-approve restriction) — 0 Critical; confirmed Critical from Round 4 is resolved by generation-token fix; 5 new Standards Warnings (2 dual-audit, 3 single-audit); W-NEW-1 still unaddressed (3rd round); Spec axis clear.
Standards
| Conf. | Where | Issue (one-liner) | |
|---|---|---|---|
| High | unified_diagnostics_notifier.dart:834,838 |
_analyzeAndShowResults() uses bare state = writes — not gated by _publish(gen,...), architectural guard gap |
|
| High | unified_diagnostics_notifier.dart:1273 |
_setError() uses bare state = write — not gated by generation, same guard gap |
|
| High | unified_diagnostics_notifier_test.dart:11-12,106,1022 |
Dead speed-test imports + mock still present after #857 disabled Speed Test (3rd round unaddressed) | |
| Med | unified_diagnostics_notifier.dart:142 |
startWithPreQualifier WAN-down: no _isCurrent(gen) guard before await selectFlow(...) — cancel between publish and selectFlow starts a new run |
|
| Med | unified_diagnostics_notifier_test.dart |
startWithPreQualifier gen-guard mid-path (cancel during WAN/ping await) has no test coverage |
|
| Med | unified_diagnostics_notifier.dart:357,379,392+ (x15) |
[Duplicated Code] step-guard + bare-write pattern repeated ~15x across 6 runners, no docs clarifying intentional exception to _publish discipline |
|
| 💡 | High | unified_diagnostics_notifier.dart:cancel(),goBack() |
[Duplicated Code] Inline scope-release logic duplicated in both cancel() and goBack() after _releaseScope removed |
| 💡 | Med | unified_diagnostics_notifier.dart:142 |
selectFlow() inside startWithPreQualifier silently bumps _generation, transferring gen ownership — needs inline comment |
Confidence: High = code-verified · Med = located + reasoned, not fully confirmed · Low = speculative, please double-check.
[X] = 1 agent · [XX] = 2 agents flagged independently → higher confidence (Critical only).
Spec
No linked spec gap detected this round. Issue #1148 requirements fully satisfied.
| Conf. | Where | Issue (one-liner) | |
|---|---|---|---|
| ✅ | High | unified_diagnostics_notifier.dart:236,257 |
#1148: ++_generation + synchronous state reset in cancel() — "stop immediately" semantics preserved and strengthened |
| ✅ | High | unified_diagnostics_notifier.dart:346-825 (all runners) |
All 6 flow runners now accept gen; every step boundary has if (!_isCurrent(gen)) return |
| ✅ | High | unified_diagnostics_notifier_test.dart:872-953 |
Regression test proves revived Run A (WAN Down) cannot clobber Run B (WAN Up) |
🔴 Critical — Evidence Chain
No Critical findings this round.
The Round 4 confirmed Critical (cancel→restart clobber race, unified-diagnostics-notifier-dart-210-223) has been resolved. Synthesizer verification against HEAD (37d2889):
_cancelledbool replaced byint _generationtoken: each run entry claims a fresh generation viafinal gen = ++_generation; all post-await state writes go through_publish(gen, ...)which no-ops if generation moved on;cancel()andgoBack()bump generation synchronously to invalidate in-flight runners.- Regression test
'revived run after cancel+restart does not clobber new run (#1175)'fingerprints Run A vs Run B with different WAN states, proves guard correctness.
The two helpers (_analyzeAndShowResults, _setError) that use bare state = writes were evaluated for Critical: since _generateRecommendations is synchronous and _analyzeAndShowResults has no real await, the Dart event loop cannot yield inside that function. The architectural gap is a future regression risk, not a current runtime bug. Downgraded to Warning per synthesis rules.
⚠️ Warning Details
W-1 [Standards A+B, High] unified_diagnostics_notifier.dart:834,838 — _analyzeAndShowResults bare state writes (dual-audit)
Both reviewers found: _analyzeAndShowResults(List<DiagnosticStepUIModel>) takes no gen parameter and uses bare state = state.copyWith(...) at lines 834 and 838. All 6 callers guard with if (!_isCurrent(gen)) return before calling, and _generateRecommendations is synchronous, so no current runtime bug. However the function signature provides no architectural enforcement: if any future maintainer adds an await inside (e.g., async cache write, AI analysis), the two bare writes will immediately become a live race bypassing the generation guard with no compile-time or lint warning.
// unified_diagnostics_notifier.dart:832-841
Future<void> _analyzeAndShowResults(
List<DiagnosticStepUIModel> results) async {
state = state.copyWith(step: DiagnosticStep.analyzing); // bare write — no gen guard
final recommendations = _generateRecommendations(results);
state = state.copyWith( // bare write — no gen guard
step: DiagnosticStep.showingResults,
recommendations: recommendations,
);
}Fix: Change signature to _analyzeAndShowResults(int gen, List<DiagnosticStepUIModel> results), replace both bare writes with _publish(gen, state.copyWith(...)). Update all 6 call sites.
W-2 [Standards A+B, High] unified_diagnostics_notifier.dart:1273 — _setError bare state write (dual-audit)
// unified_diagnostics_notifier.dart:1271-1277
void _setError(ServiceError error) {
logger.e('[Diagnostics] Error: $error');
state = state.copyWith( // bare write — no gen guard
step: DiagnosticStep.showingResults,
error: error,
);
}Current callers invoke this on the early-exit svc == null path immediately after generation is confirmed valid (no intervening await), so no current runtime bug. Same future-maintenance risk as W-1.
Fix: Add gen parameter and use _publish(gen, ...), or inline _publish at each call site.
W-3 [Standards A, High] unified_diagnostics_notifier_test.dart:11-12,106,1022-1053 — Dead speed-test imports/mock (3rd round)
At HEAD (37d2889), still present: import 'speed_test_state.dart' (line 11), import 'speed_test_notifier.dart' (line 12), speedTestProvider.overrideWith(() => _MockSpeedTestNotifier()) in createContainer() (line 106), full _MockSpeedTestNotifier class (lines 1022-1053). Production notifier has zero references to speedTestProvider since #857. Override is harmless today but will cause compile errors if speed_test_notifier.dart is deleted as part of final #857 cleanup.
Fix: Remove lines 11-12 (imports), line 106 (override), lines 1022-1053 (mock class).
W-4 [Standards A, Med] unified_diagnostics_notifier.dart:142 — startWithPreQualifier WAN-down: missing guard before await selectFlow
// ~line 137-143
_publish(gen, state.copyWith(
step: DiagnosticStep.selectFlow,
preQualifierResult: PreQualifierResult.wanDownNoIp,
));
// no _isCurrent(gen) guard here
await selectFlow(DiagnosticFlow.internet); // selectFlow immediately does ++_generation
return;If user cancels between _publish and await selectFlow (bumping generation to N+1 and resetting state), selectFlow then does ++_generation to N+2 and starts internet diagnostics, bypassing the cancel. The generation token improved the general case but this specific path still allows cancel→selectFlow to re-ignite.
Fix: Add if (!_isCurrent(gen)) return; immediately before await selectFlow(DiagnosticFlow.internet).
W-5 [Standards B, Med] unified_diagnostics_notifier_test.dart — startWithPreQualifier gen-guard mid-path untested
Two if (!_isCurrent(gen)) return guards added inside startWithPreQualifier (after WAN check and after ping). New regression test only covers selectFlow-path cancel→restart. startWithPreQualifier cancel mid-WAN-check or mid-ping has no test, extending the pre-existing W-PREV-2 gap.
Fix (follow-up): Add test for cancel during startWithPreQualifier WAN-check suspend.
✅ What Looks Good
- Round 4 confirmed Critical resolved (High): cancel→restart clobber race fully fixed. All 6 flow runners consistently accept
gen, thread it through all step boundaries, and gate every post-await state write via_publish(gen, ...). - Scope removal clean (High):
_releaseScope()helper correctly removed; bothcancel()andgoBack()running-case now capture-then-null_scopebefore the unawaited cleanup, preventing new runs from having their SSE subscription torn out. - Regression test well-designed (High): Run A (WAN Down) vs Run B (WAN Up) clearly distinguishable;
runAGatewayCompleter suspends A correctly;await runBFuturebefore releasing A ensures correct sequencing; assertions verify results length (6) and WAN verdict (isError=false). - Architecture compliance (High): Three-layer dependency intact; Provider four hard rules satisfied; no codegen model imports in wrong layer.
- Spec #1148 fully satisfied (High):
cancel()andgoBack()both bump_generationsynchronously, then immediately resetstate = const UnifiedDiagnosticsState()— UI resets before any async cleanup.
Cross-reviewed by three independent agents (security+correctness / architecture+maintainability / spec-compliance). Automated — please sanity-check before merge.
PeterJhongLinksys
left a comment
There was a problem hiding this comment.
🤖 Automated Review — Round 2 · 1e2afd6..37d2889 (incremental)
Verdict: 💬 Comment — The generation-token rewrite genuinely closes Round 1's _cancelled clobber race for state writes, but the new invariant was not applied everywhere: one runner still writes state bare after an await, and goBack()'s preQualifying branch never bumps the generation. Both make Cancel/Back silently fail again, so this needs a human decision before merge.
| Conf. | Where | Issue (one-liner) | |
|---|---|---|---|
| 🔴 [X] | 🟢 | unified_diagnostics_notifier.dart:789-795 |
Intermittent flow writes state = state.copyWith(step: pingInternet) bare after await _ensureScope() — Cancel is undone and the UI is left on a spinner that can never advance. |
| 🔴 [XX] | 🟢 | unified_diagnostics_notifier.dart:281-286 |
goBack()'s preQualifying branch resets to idle without ++_generation, so the in-flight pre-qualifier stays "current", jumps the UI to the flow menu and may auto-start a whole run the user just backed out of. |
| 🟢 | unified_diagnostics_notifier.dart:73-86 (calls at :148, :372, :529, :789) |
_ensureScope() has no generation guard: a cancelled runner still re-acquires a shared SSE scope after Cancel, and that scope is owned by nobody → leaked until dispose. |
|
| 🟢 | unified_diagnostics_notifier.dart:111-114 vs :243 |
startWithPreQualifier() never registers _runFuture, so cancel()'s teardown sees null and releases the scope without waiting for the in-flight pre-qualifier. |
|
| 🟢 | unified_diagnostics_notifier.dart:236-256 & :307-324 |
[Duplicated Code] Removing _releaseScope() grew the teardown block from ~8 shared lines to ~16 duplicated lines in two places; the ordering invariant now has two copies (and the third call site was forgotten — see Critical #2). |
|
| 🟢 | unified_diagnostics_notifier.dart:832-843, :1271-1277 |
_analyzeAndShowResults() / _setError() are async/bare state = and take no gen; safe only because they currently contain no await — adding one silently reintroduces the clobber bug. |
|
| 🟢 | unified_diagnostics_notifier_test.dart (no goBack occurrence) |
goBack() has zero test coverage although this PR changed it; the preQualifying regression above is unguarded. |
|
| 🟡 | unified_diagnostics_notifier.dart:57-67 (ref.onDispose) |
Dispose releases the scope but does not bump _generation, so a runner revived after auto-dispose passes _isCurrent and writes to a disposed notifier. |
|
| 💡 | 🟢 | unified_diagnostics_notifier.dart (27 guards + 38 _publish + 32 bare state =) |
[Shotgun Surgery] Each new step must now remember two things (guard and _publish) plus threading gen through the signature; forgetting _publish compiles fine and silently regresses. A _step(gen, step, body) helper would collapse this. |
| 💡 | 🟡 | unified_diagnostics_notifier_test.dart:919-947 |
New regression test leans on three await Future.delayed(Duration.zero) scheduling assumptions and the magic number 6 for results.length; prefer explicit Completer handshakes and asserting the step list. |
| 💡 | 🟡 | unified_diagnostics_notifier_test.dart:882-896 |
Inline ad-hoc WanStatusUIModel(...) instead of a Test Data Builder (Art. VIII). |
| 💡 | 🟡 | unified_diagnostics_notifier.dart:120-125 |
_publish used on a path with no preceding await (gen is trivially current) — evidence that "which writes must go through _publish" is an unwritten convention; please document it on _publish. |
| 💡 | 🟡 | unified_diagnostics_notifier.dart:232-258; caller diagnostic_results_view.dart:74 |
Round 1 item still open: cancel() returns Future<void> but the teardown is unawaited, so await cancel() returns before the scope is released. |
| 💡 | ⚪ | unified_diagnostics_notifier.dart:233 |
Round 1 item still open: cancel() logs at logger.d while other lifecycle events use logger.i. |
Conf.: 🟢 High (read head code, evidence attached) · 🟡 Med (file:line + rationale, not fully proven) · ⚪ Low (speculative). [X] = raised by 1 agent · [XX] = both agents.
🔴 Critical #1 — Intermittent flow: bare post-await state write defeats Cancel and wedges the UI
Location: lib/page/unified_diagnostics/providers/unified_diagnostics_notifier.dart:776-825
Code (head):
776: Future<void> _runIntermittentDiagnostics(int gen) async {
777: if (!_isCurrent(gen)) return; // entry guard (synchronous)
...
788: try {
789: await _ensureScope(); // long await (quiesce + subscribe POSTs)
790: } catch (e) {
791: logger.e('[Diagnostics] Failed to acquire scope: $e');
792: }
793:
794: // Step 1: Check intermittent issues (uptime + jitter)
795: state = state.copyWith(step: DiagnosticStep.pingInternet); // ← bare, post-await, no guard
...
818: _publish(gen, state.copyWith(results: List.from(results))); // blocked (correct)
824: if (!_isCurrent(gen)) return; // blocked (correct)
825: await _analyzeAndShowResults(results);Every sibling runner does guard the same position, which shows this is an omission rather than intent:
// _runFullDiagnosticFlow // _runInternetDiagnostics
371: await _ensureScope(); 529: await _ensureScope();
378: if (!_isCurrent(gen)) return; 535: if (!_isCurrent(gen)) return;
379: state = state.copyWith(...) 536: state = state.copyWith(...)Why it breaks (timeline):
selectFlow(intermittent)(:202-218) claimsgen, and note it does not setstep— sostepis stillselectFlowand the UI rendersDiagnosticFlowMenu, which has a Cancel button (views/widgets/diagnostic_flow_menu.dart:131 onTap: () => notifier.cancel()).- The runner parks on
:789 await _ensureScope()(bridge quiesce + shared-session subscribe — seconds, not microseconds). - User taps Cancel →
:236 ++_generation,:240-241capture-and-null_scope,:257 state = const UnifiedDiagnosticsState()→ UI back to the start screen. _ensureScope()completes, the stale runner resumes and executes:795unconditionally →step = pingInternet.views/unified_diagnostics_view.dart:69-77maps any unlisted step via_ => DiagnosticRunningView(state: state)→ the cancelled progress screen reappears with an empty results list.- All later writes are correctly rejected (
:818,:821,:824) → no further state change ever happens; the spinner stays onpingInternetforever. The user must hit Cancel a second time.
Trigger: select the Intermittent connection flow, then tap Cancel before the diagnostic scope finishes being acquired — exactly the "honor Cancel mid-flow" behaviour #1148 is about.
Suggested fix:
try {
await _ensureScope();
} catch (e) { ... }
+ if (!_isCurrent(gen)) return;
state = state.copyWith(step: DiagnosticStep.pingInternet);Better: route step markers through _publish(gen, ...) too, or wrap "guard → set step → await → publish" in a single _step(gen, ...) helper so a new step cannot forget either half.
🔴 Critical #2 — goBack()'s preQualifying branch does not invalidate the run
Location: unified_diagnostics_notifier.dart:277-286, :111-143; caller views/unified_diagnostics_view.dart:124-134
Code (head):
277: bool goBack() {
278: switch (state.step) {
281: case DiagnosticStep.preQualifying: // ← this IS a running state
282: case DiagnosticStep.selectFlow:
283: case DiagnosticStep.manualTools:
285: state = const UnifiedDiagnosticsState(); // no ++_generation, no scope handover
286: return true;
...
303: default:
307: ++_generation; // only the running branch bumps111: Future<void> startWithPreQualifier() async {
112: final gen = ++_generation;
114: state = const UnifiedDiagnosticsState(step: DiagnosticStep.preQualifying);
131: final wan = await svc.checkWanStatus(); // user presses Back here
132: if (!_isCurrent(gen)) return; // gen was NOT bumped → passes
135: _publish(gen, state.copyWith(step: DiagnosticStep.selectFlow, ...)); // overwrites idle
142: await selectFlow(DiagnosticFlow.internet); // starts a full run// views/unified_diagnostics_view.dart
130: final handledInternally = notifier.goBack(); // returns true → route is NOT poppedWhy it breaks: while the pre-qualifier loader is on screen (_buildPreQualifying, :80-100), the app-bar back handler calls goBack(), which lands on the preQualifying case: state is reset to idle and true is returned, so the user stays on the page at the start screen. The pre-qualifier's generation is untouched, so when checkWanStatus() resolves the guard at :132 passes and the runner publishes step: selectFlow over the start screen. On the WAN-down path it additionally calls selectFlow(internet) at :142, launching the entire diagnostic the user just backed out of. Any scope acquired at :148 is also never released by this branch (only the default: branch handles scope).
Trigger: start screen → "Choose specific issue" → press Back while the pre-qualifier spinner is up (a USP GET, easily a few seconds on a real router).
Suggested fix: extract void _invalidateActiveRun() (bump generation + capture-and-null _scope + queue teardown) and call it from cancel(), goBack()'s preQualifying branch and the default: branch alike; also register the pre-qualifier future in _runFuture so the teardown actually waits for it.
⚠️ Warnings — details
_ensureScope()has no run identity (:73-86). After Cancel,_scopeisnull, so a stale runner reaching:372/:529/:789/:148callsexecutor.acquireScope()for real (subscribe POSTs go out after the user cancelled) and then writes_scope = scope; _svc?.attachScope(scope);at:83-84. That scope is not the onecancel()captured, so it survives untilref.onDispose; in a cancel-then-restart interleaving it can also overwrite the newer run's_scope, orphaning the newer scope's ref-count. Fix:_ensureScope(int gen)and, after the await,if (!_isCurrent(gen)) { await scope.release(); ... }.startWithPreQualifier()never sets_runFuture(:111-199vs:95/:219).cancel()'s teardown (:243) therefore seesnulland releases the captured scope while the pre-qualifier is still insideawait svc.pingInternet(repeatCount: 1)(:149). Combined with the item above this is a real scope-lifecycle hole.- Teardown duplication (
:236-256vs:307-324). Round 1 asked for one helper; this round deleted_releaseScope()and inlined two verbatim copies (~16 lines each, differing only in the log string). The ordering invariant "bump gen → capture scope → null field → await inFlight → release" now lives in two places, and Critical #2 is exactly the site that was missed. Capturing inside a helper is perfectly safe — the Round 1 problem was reading the live_scope, not the helper itself. _analyzeAndShowResults()(:832-843) and_setError()(:1271-1277) take nogenand writestatebare. They are safe today only because neither contains anawaitbefore the write; adding one (e.g. making recommendation generation async) reopens this PR's bug with no test to catch it.goBack()is not covered by any test (no occurrence inunified_diagnostics_notifier_test.dart), even though this PR modified its running branch. Suggested cases: (a)goBack()while running — revived runner must not write and the scope is released exactly once; (b)goBack()duringpreQualifying— pre-qualifier must not jump to the flow menu or auto-start a flow; (c) slow_ensureScope()+ Cancel + new run — no second scope acquired.ref.onDispose(:57-67) does not bump_generation. A runner revived after auto-dispose still passes_isCurrent, and_publish'sstate = next(:52) would throw on a disposed notifier. We could not prove a route that disposes without going throughcancel()(views/unified_diagnostics_view.dart:136-142always cancels), so this is filed as a defensive gap — a one-line++_generation;inonDisposemakes it symmetric with cancel/goBack.- Provider coverage (≥85 %, Art. I/VIII) could not be measured in the review sandbox (no Flutter/Dart toolchain). Please attach the Provider coverage number for this file.
✅ What looks good
- Round 1's Critical is genuinely fixed for state writes.
bool _cancelled→ monotonic_generation+_isCurrent(gen)+_publish(gen, next)(:35-53); all three entry points claimfinal gen = ++_generation(:91,:112,:204) and thread it into the runners (:213-217). All 38 post-awaitresultswrites are correctly rejected for a stale runner. - Round 1's "late write pollutes idle results" Warning is fixed — Cancel bumps the generation first, so no
copyWith(results:)can land on the idle state. - Round 1's "
_releaseScope()may free the new run's scope" Warning is fixed —cancel()(:240-241) andgoBack()(:308-309) now capture-then-null, so each teardown only releases the scope it owns. - The new regression test is meaningful, not a tautology:
:872-957fingerprints Run A (WAN Down) vs Run B (WAN Up) and assertsresults.length == 6plusisError == false, which fails on the pre-fix code and passes with the guard. - No security findings: no hard-coded secrets/tokens, no
flutter_secure_storagemisuse, no injection surface; the increment only touches state-machine control flow. - USP error handling intact: every
catchin this feature is untypedcatch (e)(noon Exception catchanywhere underlib/page/unified_diagnostics/), so raw-String USP errors are still caught; the new guards sit outside thetryblocks and change no catch boundary. - Layering clean: no
generated/import inproviders/, no reverse dependency onviews/; Art. IV (no mutation here) and Art. XIII are untouched. Naming (_generation/_isCurrent/_publish) is clear, and the:35-43DartDoc documents the design intent and the superseded bug well.
Cross-reviewed by independent agents (security+correctness / architecture+maintainability). Automated — please sanity-check before merge.
🔴 Adversarial re-review CONFIRMED this Critical [unified-diagnostics-notifier-dart-789-795]An independent read-only reviewer (different model) traced the finding to its sink and confirmed it is a real issue (not a false positive). An automated review-fix task ( Automated by gate.py confirm-lane. Merge / ready-for-review / closing remain manual (Austin). |
🔴 Adversarial re-review CONFIRMED this Critical [unified-diagnostics-notifier-dart-281-286]An independent read-only reviewer (different model) traced the finding to its sink and confirmed it is a real issue (not a false positive). An automated review-fix task ( Automated by gate.py confirm-lane. Merge / ready-for-review / closing remain manual (Austin). |
…nst cancel race (#1175) The _runIntermittentDiagnostics flow performed a bare state=copyWith(step: pingInternet) write after the suspending await _ensureScope() with no _isCurrent(gen) recheck -- the one flow where the generation guard was omitted while every sibling flow had it. A Cancel during scope acquisition resets state to idle, then the stale runner clobbers it back to step=pingInternet with flow=null, stranding the running view on a spinner. Add the missing guard so the stale write is a no-op, plus a focused regression test for the scope-acquire race.
🔧 Review-fix applied (L2) — commit
|
PeterJhongLinksys
left a comment
There was a problem hiding this comment.
🤖 Automated Review — Round 3 · 37d2889..0f75a2b (incremental)
Verdict: 💬 Comment — The one-line fix at :795 is correct, minimally scoped, and backed by a regression test that genuinely fails without it. However, Round 2's Critical #2 (goBack()'s preQualifying branch) is still completely unfixed even though the confirm-lane comment marked it CONFIRMED, and the new test happens to exercise the known _ensureScope() scope-ownership hole while asserting only step — so it green-lights the leak. Needs a human decision before merge.
| Conf. | Where | Issue (one-liner) | |
|---|---|---|---|
| 🔴 [XX] | 🟢 | unified_diagnostics_notifier.dart:281-286 |
Round 2 Critical #2 still unfixed: goBack()'s preQualifying branch resets to idle without ++_generation/scope handover, so the in-flight pre-qualifier survives, jumps the UI to the flow menu and may auto-start the run the user backed out of. |
| 🟢 | unified_diagnostics_notifier.dart:73-86 + ..._test.dart:970-1010 |
_ensureScope() still has no generation guard — and the new test actually drives a revived stale runner through _scope = scope; _svc?.attachScope(scope) (:83-84) yet asserts only step, so the ownerless-scope leak passes as expected behaviour. |
|
| 🟢 | unified_diagnostics_notifier.dart:111-199 |
startWithPreQualifier() still never assigns _runFuture, so cancel()'s teardown (:243) sees null and releases the scope while the pre-qualifier is still awaiting. |
|
| 🟢 | ..._notifier_test.dart (no goBack occurrence) |
goBack() still has zero test coverage, and the outstanding Critical lives inside it. |
|
| 🟢 | unified_diagnostics_notifier.dart:236-256 vs :307-324 |
[Duplicated Code] Teardown remains two ~16-line verbatim copies of the same ordering invariant; the missed third site is exactly the Critical above. | |
| 🟢 | unified_diagnostics_notifier.dart:833-845, :1272-1279 |
_analyzeAndShowResults() / _setError() are still async with bare state = and take no gen — safe only while they contain no await. |
|
| 🟢 | ..._notifier_test.dart:980-989 (also :669, :694) |
[Duplicated Code] Third inline ad-hoc IntermittentUIModel(...) instead of a Test Data Builder (Art. VIII); test/mocks/test_data/ has none for unified_diagnostics. |
|
| 🟢 | ..._notifier_test.dart:974-979 |
[Temporal Coupling] acquireCalls == 1 ? hang : resolve binds to a global call index, not to a run; it silently changes meaning once _ensureScope() gains a guard or a retry. |
|
| 🟡 | ..._notifier_test.dart:993, :1000, :1010 |
Three new await Future.delayed(Duration.zero) scheduling assumptions; if any await is added before acquireScope(), the test silently passes for the wrong reason. |
|
| 🟡 | unified_diagnostics_notifier.dart:232-256; test :1009 |
cancel()'s teardown is unawaited, so await cancelFuture in the new test is a no-op — the test's stability relies on cancel() doing its synchronous part before the first await. |
|
| 🟡 | unified_diagnostics_notifier.dart:1019-1023; service :149-152 |
[Art. V §5.4] Pre-existing layering leak: getDnsClient() returns the generated DnsClient, and the Notifier reads its fields directly (the test must import generated/dns_client.g.dart). Not introduced here — filed for tracking. |
|
| ⚪ | unified_diagnostics_notifier.dart:788-796 |
_ensureScope() failure is only logged (:791) and execution continues, so the user sees "intermittent check failed" instead of "cannot open a diagnostic session". Pre-existing, but these lines were touched. |
|
| 💡 [XX] | 🟢 | unified_diagnostics_notifier.dart (28 guards / 46 _publish) |
[Shotgun Surgery] Each step must still remember two things (guard and _publish) plus thread gen; this PR is that design flaw billing a second time. A _step(gen, step, body) helper would remove the class of bug. |
| 💡 | 🟢 | ..._notifier_test.dart:1012-1015 |
Asserts only step; its sibling (:930-951) also fingerprints results.length / isError. A future bare write before :796 would keep this test green. |
| 💡 | 🟡 | ..._notifier_test.dart:967, :1016 |
container.dispose() at the end of the body instead of addTearDown(container.dispose) — leaks the container if an expect fails. |
| 💡 | 🟢 | ..._notifier_test.dart test name ... (#1175) |
Test name embeds the issue number; Art. VIII asks names to describe behaviour (the number is already in the comment). |
| 💡 | 🟡 | ..._notifier_test.dart:955-963 (comment) |
The comment asserts intermittent was "the one flow missing" the recheck — true today (verified across all 5 runners) but a uniqueness claim that rots. |
| 💡 | 🟡 | unified_diagnostics_notifier.dart:50-53 |
Round 2 item open: _publish's DartDoc still doesn't state which writes must go through it — this fix chose bare-write-plus-guard, so both idioms now coexist undocumented. |
| 💡 | ⚪ | unified_diagnostics_notifier.dart:57-67 |
Round 2 item open: ref.onDispose does not bump _generation, so it is asymmetric with cancel()/goBack() (defensive gap, no proven route). |
| 💡 | ⚪ | unified_diagnostics_notifier.dart:233 |
Round 2 item open: cancel() logs at logger.d while sibling lifecycle events use logger.i. |
Conf.: 🟢 High (read head code, evidence attached) · 🟡 Med (file:line + rationale, not fully proven) · ⚪ Low (speculative). [X] = raised by 1 agent · [XX] = both agents.
🔴 Critical — goBack()'s preQualifying branch still does not invalidate the in-flight run (carried over from Round 2, unfixed)
Location: lib/page/unified_diagnostics/providers/unified_diagnostics_notifier.dart:281-286 (contrast :303-309); in-flight runner at :111-148; caller views/unified_diagnostics_view.dart:130.
Code (head 0f75a2be, re-read this round — unchanged from Round 2):
277: bool goBack() {
278: switch (state.step) {
279: case DiagnosticStep.idle:
280: return false;
281: case DiagnosticStep.preQualifying: // <- this IS a running state
282: case DiagnosticStep.selectFlow:
283: case DiagnosticStep.manualTools:
284: // Back to start screen
285: state = const UnifiedDiagnosticsState(); // no ++_generation, no scope handover
286: return true;
...
303: default:
307: ++_generation; // only the running branch invalidates
308: final scope = _scope;
309: _scope = null;111: Future<void> startWithPreQualifier() async {
112: final gen = ++_generation;
114: state = const UnifiedDiagnosticsState(step: DiagnosticStep.preQualifying);
131: final wan = await svc.checkWanStatus(); // user presses Back here
132: if (!_isCurrent(gen)) return; // gen was NOT bumped -> passes
135: _publish(gen, state.copyWith(step: DiagnosticStep.selectFlow, ...)); // overwrites idle
142: await selectFlow(DiagnosticFlow.internet); // starts a full run
148: await _ensureScope(); // scope this branch never releases// views/unified_diagnostics_view.dart
130: final handledInternally = notifier.goBack(); // true -> route is NOT poppedWhy it breaks (timeline):
- Start screen → "Choose specific issue" →
startWithPreQualifier()claimsgen(:112) and renders the pre-qualifier loader (unified_diagnostics_view.dart:71). - The runner parks on
:131 await svc.checkWanStatus()— a real USP GET, easily hundreds of ms to seconds on a router. - User taps app-bar Back →
goBack()lands on:281→state = const UnifiedDiagnosticsState()and returnstrue, so the route is not popped and the user sees the start screen._generationuntouched,_scopenot handed over, no teardown queued. checkWanStatus()resolves → the guard at:132passes →:135 _publishstampsstep: selectFlowover the freshly reset idle state. On the WAN-down/no-IP path it additionally runs:142 await selectFlow(DiagnosticFlow.internet), launching the entire diagnostic (including_ensureScope()subscribe POSTs) that the user just backed out of.
Trigger: start screen → "Choose specific issue" → press Back while the pre-qualifier spinner is up.
Suggested fix: extract void _invalidateActiveRun() (++_generation + capture-and-null _scope + queue teardown) and call it from cancel() (:236-256), the preQualifying branch (:281) and default: (:303) alike — this also removes the duplicated teardown (Warning above). Minimal patch: move DiagnosticStep.preQualifying out of the :281 group. Also register the pre-qualifier in _runFuture so the teardown actually waits for it.
Note on process: the confirm-lane comment on this PR marked [unified-diagnostics-notifier-dart-281-286] as CONFIRMED, but git diff --stat 37d2889..0f75a2b is notifier.dart +1 / notifier_test.dart +64 — only Critical #1 was addressed.
⚠️ Warnings — details
_ensureScope()has no run identity, and the new test now certifies the leak (:73-86, test:970-1010). Timeline inside the new test: Run A suspends insideacquireScope()(socancel()at:240capturesnulland releases nothing) → Run B acquires its own scope → Run A revives and still executes:82-84final scope = await executor.acquireScope(); _scope = scope; _svc?.attachScope(scope);, i.e. a dead run overwrites the live_scopeand re-attaches it to the Service, and only then is stopped by the new guard at:795. Nothing releases that scope untilref.onDispose. The test asserts onlystep, so there is noverify(() => mockExecutor.acquireScope(...)).called(2), noverifyNever(attachScope), no release count. Fix:_ensureScope(int gen)and, after the await,if (!_isCurrent(gen)) { await scope.release(); return scope; }— then add those assertions here.startWithPreQualifier()still never sets_runFuture(:111-199vs:95,:219).cancel()'s teardown at:243readsnulland releases the captured scope while the pre-qualifier may still be inside:149 await svc.pingInternet(repeatCount: 1).- Teardown duplication (
:236-256vs:307-324). Two verbatim copies differing only in the log string; the invariant "bump gen → capture scope → null field → await inFlight → release" has two homes and the Critical above is the third, forgotten one. Capturing inside a shared helper is safe — the Round 1 problem was reading the live_scope, not the helper. _analyzeAndShowResults()(:833-845) and_setError()(:1272-1279) take nogenand writestatebare. They are safe only because noawaitprecedes the write today; making recommendation generation async reopens this PR's exact bug — and the new test would not catch it, because the clobbered value there also happens to beshowingResults.- Test data (Art. VIII):
IntermittentUIModel(...)is now spelled out inline three times in this file (:669,:694,:980-989) whiletest/mocks/test_data/already hosts builders for devices/wifi. AUnifiedDiagnosticsTestData.intermittentOk()+copyWithwould remove the drift risk. acquireCallsstub (:974-979) encodes "first global acquire hangs". It also overrides thesetUpdefault 60 lines away, so the effective behaviour is non-local, and it will keep passing with a different meaning once_ensureScope()is guarded. Prefer a per-runCompleter(or a queue of futures) plus an explicitverify(...).called(2).await cancelFuture(test:1009) is a no-op becausecancel()'s teardown isunawaited(...)(:242). The test is stable only becausecancel()completes++_generation/_scope = null/state = idle(:236-257) synchronously before its firstawait. Either makecancel()'s future cover the teardown (open since Round 1) or document why the await is meaningless here._ensureScope()failure is swallowed (:788-792). Afterlogger.e, control falls through to:796and:798 svc.checkIntermittent(), which will almost certainly fail without a scope — the user gets "intermittent check failed" rather than "could not open a diagnostic session". Pre-existing, but this is the block the PR touches;_setError(ConnectivityError(...)); return;would be a cheap improvement.- Layering (Art. V §5.4) — correction to Round 2's "layering clean" note.
UnifiedDiagnosticsService.getDnsClient()returns the generatedDnsClient(unified_diagnostics_service.dart:149-152) and the Notifier readsdns.servers[].addressdirectly (:1019-1023); the test file mustimport 'package:privacy_gui/generated/dns_client.g.dart'to build fixtures. The Notifier avoids a literalgenerated/import only via type inference. Pre-existing and out of scope for this PR, but worth a follow-up returning a UI model. - Provider coverage (≥85 %, Art. I/VIII) could not be measured — no Flutter/Dart toolchain in the review sandbox. Please attach the Provider coverage number for this file. The author's reported
26/26 passed, cleanflutter analyzeand cleandart formatare plausible and consistent with the diff, but were not independently reproduced.
✅ What looks good
- Round 2's Critical #1 is genuinely and correctly fixed. Head
:788-796:The guard sits after the suspending await and before the write, outside the788: try { 789: await _ensureScope(); 790: } catch (e) { 791: logger.e('[Diagnostics] Failed to acquire scope: $e'); 792: } 793: 794: // Step 1: Check intermittent issues (uptime + jitter) 795: if (!_isCurrent(gen)) return; 796: state = state.copyWith(step: DiagnosticStep.pingInternet);
try— byte-for-byte the pattern the sibling flows already use (:377-379Full,:534-536Internet). It restores an existing convention rather than inventing a new mechanism. - All five runners were re-checked this round: every
state = copyWith(step: ...)is now preceded byif (!_isCurrent(gen)) return;, and every post-awaitresultswrite goes through_publish(gen, ...). The author's "last remaining flow" claim holds within the runners. - The new regression test is a real guard, not a tautology. Reverting the guard leaves the revived Run A stamping
step = pingInternet, after which:819/:822/:825reject everything else — the terminalstepstayspingInternetandexpect(finalState.step, showingResults)(:1013) fails. The mocks are self-consistent:cancel()nulls_scope, so Run B really does callacquireScope()a second time;IntermittentUIModel(...)matches the constructor inunified_diagnostics_service.dart(uptimeFormattedis a getter);registerFallbackValue(_FakeScope())and theattachScope(any())stub already exist insetUp. - Scope discipline (Art. I §1.3): 2 files, 65 lines, no drive-by lint/format churn; the hang point (scope acquisition) is deliberately different from the existing
#1175internet-flow test (gateway ping), so the two tests do not overlap. - No security findings. The increment is pure control flow plus a test: no hard-coded secrets/tokens, no
flutter_secure_storagemisuse, no injection surface, no permission logic touched, no sensitive data logged (:791logs a scope-acquire error only). - USP/JNAP error handling intact.
on Exception catchhas zero occurrences underlib/page/unified_diagnostics/— all catches are barecatch (e), so raw-String USP errors are still caught;unified_diagnostics_service.dart:522-523still funnels throughmapUspErrorToServiceError(e). The new guard is outside thetry, so no catch boundary moved. - Art. IV / XIII / III / XI / XV: no mutation, no new L1 provider, no
PreservableContractredefinition, no catch-boundary or ServiceError change, no new UI component or model._generation/_isCurrent/_publishremain clear names, and the new test's comment explains the hang point and the expectation well. - Round 1/2 fixes have not regressed: capture-then-null scope (
:240-241,:308-309) andfinal gen = ++_generationat all three entry points (:91,:112,:204) are intact.
Cross-reviewed by independent agents (security+correctness / architecture+maintainability). Automated — please sanity-check before merge.
The goBack() preQualifying/selectFlow/manualTools branch bumped the run generation but never captured/nulled _scope, scheduled scope release, or updated _teardownFuture — unlike cancel() and the running default branch. startWithPreQualifier acquires the scope (via _ensureScope) before its pingInternet step, so pressing Back while that ping is in flight left a live DiagnosticScope that _ensureScope could reuse for a new runner, and left teardownDone unable to reflect real completion. Apply the same 3-step invariant: capture scope + null _scope, drain the in-flight _runFuture then release the scope off the critical path, and track it via _teardownFuture. State reset stays synchronous for UI responsiveness. Add a regression test asserting the captured scope is released and teardownDone resolves after Back during preQualifying.
🔧 Review-fix (L2): W-6 —
|
AustinChangLinksys
left a comment
There was a problem hiding this comment.
🤖 Automated Review — Round 8 · c76634b..8f7dde0 (incremental)
Verdict: 💬 COMMENT — H-1 still outstanding: startWithPreQualifier() never assigns _runFuture; all teardown lambdas drain null and release scope while ping is live.
Standards
| Conf. | Where | Issue (one-liner) | |
|---|---|---|---|
| 🔴 [XX] | 🟢High | unified_diagnostics_notifier.dart:125-213 |
H-1: startWithPreQualifier() never assigns _runFuture — new preQualifying teardown drains null, releases scope immediately while pingInternet is live (both agents; pre-existing gap, still unresolved) |
| 🟢High | unified_diagnostics_notifier.dart:355 |
W-GOBACK-RUNNING: goBack() default/running branch still uses bare unawaited(), never updates _teardownFuture — teardownDone stale for mid-run back-nav (both agents) |
|
| 🟢High | unified_diagnostics_view.dart:136-143 |
W-RETURN-MENU: _returnToMenu fire-and-forgets cancel() with no teardownDone await — same re-entry race as the one fixed in _returnToDashboard (both agents) |
|
| 🟡Med | diagnostic_results_view.dart:78-80 |
W-CONTEXT-MOUNTED: cancel() resets state synchronously → widget unmounts before teardownDone resolves → context.mounted silently drops the dashboard navigation (both agents) |
|
| 🟢High | unified_diagnostics_notifier.dart:43,47 |
W-STALE-TEARDOWN: _teardownFuture never cleared after completion — permanently resolves immediately after first cancel(); false guarantee during any subsequent active run (both agents) |
|
| ✅ | 🟢High | unified_diagnostics_notifier.dart:305-329 |
W-6 resolved: goBack() preQualifying branch now executes full 3-step invariant — ++_generation, capture+null _scope, drain+release via _teardownFuture |
| ✅ | 🟢High | unified_diagnostics_notifier.dart:43,47,256-271 |
H-4 resolved: _teardownFuture field + teardownDone getter added; cancel() now tracks its teardown future correctly |
| ✅ | 🟢High | diagnostic_results_view.dart:76-79 |
H-4 resolved: _returnToDashboard now await cancel() + await teardownDone — scope-release ordering restored for dashboard exit |
| 💡 | — | unified_diagnostics_notifier.dart:250,314,352 |
[DuplicatedCode] S-1: Extract _invalidateActiveRun() — 3-step teardown invariant now triplicated across cancel(), goBack()-preQualifying, and goBack()-running (both agents; running branch also missing assignment, see W-GOBACK-RUNNING) |
| 💡 | — | unified_diagnostics_notifier.dart:45-47 |
S-3: DartDoc for teardownDone claims coverage of [goBack] — running branch excluded until W-GOBACK-RUNNING fixed |
| 💡 | — | test/.../unified_diagnostics_notifier_test.dart |
S-2: New test asserts release() called once but not drain-before-release ordering — add verifyNever(() => mockScope.release()) before pingCompleter.complete() to enforce invariant |
| 💡 | — | test/.../unified_diagnostics_notifier_test.dart |
S-4: No test for cancel() while checkWanStatus is still suspended (scope not yet acquired) — distinct path from the new goBack() test |
Confidence: 🟢High = code-verified · 🟡Med = located + reasoned, not fully confirmed · ⚪Low = speculative, please double-check.
[X] = 1 agent · [XX] = 2 agents flagged independently → higher confidence (Critical only).
Spec
| Conf. | Where | Issue (one-liner) | |
|---|---|---|---|
| ✅ | 🟢High | unified_diagnostics_notifier.dart:305-329 |
Spec-H-1 (Round 8): preQualifying Back path now does full teardown — matches "Action should stop immediately" for back-nav from pre-qualifier |
| 🟢High | spec#\"Action should stop immediately\" → unified_diagnostics_notifier.dart:355 |
Spec-W-1: goBack() running branch teardown not tracked by _teardownFuture — same as W-GOBACK-RUNNING; mid-run Back also should stop immediately |
|
| 🟢High | spec#\"Action should stop immediately\" → unified_diagnostics_notifier.dart:125-213 |
Spec-W-2: startWithPreQualifier drain no-op — overlaps H-1; preQualifier cancel does not drain in-flight ping before releasing scope |
|
| 💡 | 🟢High | spec#\"Click Cancel Diagnostics button\" → unified_diagnostics_view.dart:80-100 |
Spec-S-1: No Cancel button rendered during preQualifying spinner — spec scenario only exercisable via app-bar Back, not Cancel button |
Linked spec: issue #1148 "Cancel Diagnostics button not action immediately".
Confidence: 🟢High = code-verified · 🟡Med = located + reasoned, not fully confirmed · ⚪Low = speculative, please double-check.
[X] = 1 agent · [XX] = 2 agents flagged independently → higher confidence (Critical only).
🔴 Critical — Evidence Chain
H-1 [XX] startWithPreQualifier() — _runFuture never assigned; new preQualifying teardown drains null; scope released while ping is live
Position: unified_diagnostics_notifier.dart:125-213 (entry point), :314-317 (new W-6 teardown drain), :256-271 (cancel teardown)
Code — how other entry points register _runFuture correctly:
// runFullDiagnostic (line 103-115):
final future = _runFullDiagnosticFlow(gen);
_runFuture = future; // ← assigned before first await
try {
await future;
} finally {
if (identical(_runFuture, future)) _runFuture = null;
}
// selectFlow (line 219-237): same pattern, _runFuture = future before awaitCode — startWithPreQualifier (lines 125-213) — _runFuture never assigned:
Future<void> startWithPreQualifier() async {
final gen = ++_generation;
// ← NO _runFuture assignment anywhere in this method
state = const UnifiedDiagnosticsState(step: DiagnosticStep.preQualifying);
...
try {
final wan = await svc.checkWanStatus(); // suspend point A — _runFuture null
...
await _ensureScope(); // scope acquired — _runFuture still null
final pingResult = await svc.pingInternet(repeatCount: 1); // suspend point B — _runFuture null
...
}
}Code — new preQualifying teardown drain (lines 314-317) — always skips:
_teardownFuture = () async {
final inFlight = _runFuture; // ← always null when startWithPreQualifier ran
if (inFlight != null) { // ← always false — drain unconditionally skipped
try { await inFlight; } catch (_) {}
}
if (scope != null) {
try {
await scope.release(); // ← fires IMMEDIATELY while pingInternet is live
} catch (e) { ... }
}
}();Same no-drain in cancel() teardown (lines 256-271): Identical inFlight = _runFuture read → null → skip → immediate scope.release().
Why this is a bug: When goBack() or cancel() fires while startWithPreQualifier is suspended at checkWanStatus (suspend A) or pingInternet (suspend B), _runFuture == null → drain is skipped → scope.release() fires immediately. The in-flight service call (pingInternet) continues on a scope that has been signaled for teardown — DiagnosticScope._ensureLive() throws StateError on a released scope; or if endSharedSession() arms the linger timer and another entry gets a concurrent acquire, the scope may be reused while the old op is completing.
Why the new test does NOT surface this: test: back during preQualifying releases the acquired scope (#1175) completes the ping via pingCompleter.complete(...) and await preQualFuture before await notifier.teardownDone. In production, the user fires Back while the ping is still awaiting a network response — _runFuture is null, drain skips, scope.release() fires around the live ping.
Trigger condition: User navigates to Network Diagnostics → taps "Choose specific issue" → startWithPreQualifier acquires scope at _ensureScope() → pingInternet is awaited → user taps Cancel or Back. Reproducible in 3 taps.
Fix (mirrors runFullDiagnostic / selectFlow):
Future<void> startWithPreQualifier() async {
final gen = ++_generation;
state = const UnifiedDiagnosticsState(step: DiagnosticStep.preQualifying);
final future = _runPreQualifierFlow(gen); // extract body into private helper
_runFuture = future;
try {
await future;
} finally {
if (identical(_runFuture, future)) _runFuture = null;
}
}This also makes the _invalidateActiveRun() extraction (S-1) complete and structurally sound.
⚠️ Warning Details
W-GOBACK-RUNNING [Dual-audit, 🟢High] unified_diagnostics_notifier.dart:355 — goBack() running branch uses bare unawaited(), _teardownFuture not updated
default:
++_generation;
final scope = _scope;
_scope = null;
unawaited(() async { // ← bare unawaited, _teardownFuture NOT assigned
final inFlight = _runFuture;
...
await scope.release();
}());After goBack() from any running step, _teardownFuture still holds the previous completed future (from the last cancel() or preQualifying goBack()). teardownDone resolves immediately — false guarantee the current back-nav's scope release has completed.
B2 Verdict: True bug, dual-audit, in-scope → ✅ review-fix card queued.
Fix: Apply same _teardownFuture = () async { ... }(); unawaited(_teardownFuture!); pattern, or extract _invalidateActiveRun() (S-1).
W-RETURN-MENU [Dual-audit, 🟢High] unified_diagnostics_view.dart:136-143 — _returnToMenu fire-and-forgets cancel(), no teardownDone await
void _returnToMenu(BuildContext context, WidgetRef ref) {
ref.read(unifiedDiagnosticsProvider.notifier).cancel(); // ← fire-and-forget
if (context.canPop()) {
context.pop(); // ← synchronous nav before teardown starts
} else {
context.goNamed(RouteNamed.uspMenu);
}
}Called from: (1) "Return to dashboard" button on completed screen (line 117); (2) _handleBack() fallthrough when goBack() returns false (idle state). Navigation fires before the background scope.release() even begins — exact race fixed in _returnToDashboard but missing here.
B2 Verdict: True bug, dual-audit, in-scope → ✅ review-fix card queued.
Fix:
Future<void> _returnToMenu(BuildContext context, WidgetRef ref) async {
final notifier = ref.read(unifiedDiagnosticsProvider.notifier);
await notifier.cancel();
await notifier.teardownDone;
if (!context.mounted) return;
if (context.canPop()) { context.pop(); } else { context.goNamed(RouteNamed.uspMenu); }
}Update callsites to handle async void.
W-CONTEXT-MOUNTED [Dual-audit, 🟡Med] diagnostic_results_view.dart:78-80 — context.mounted silently drops navigation after await teardownDone
await notifier.cancel(); // ← state = UnifiedDiagnosticsState() fires HERE
await notifier.teardownDone; // ← background drain; 1+ frames cross this await
if (!context.mounted) return; // ← DiagnosticResultsView was unmounted by the state reset
context.goNamed(RouteNamed.uspDashboard); // ← never reachedcancel() is synchronous except for the unawaited teardown. Its final line is state = const UnifiedDiagnosticsState(), which triggers a rebuild → _buildContent returns DiagnosticStartView → DiagnosticResultsView unmounts. teardownDone resolves at least one microtask later. By then context.mounted == false → navigation silently dropped. User lands on the idle diagnostics screen instead of the USP dashboard.
B2 Verdict: True bug, dual-audit, in-scope → ✅ review-fix card queued (low churn fix).
Fix: Capture router reference before the await, or navigate first and await teardown after:
Future<void> _returnToDashboard(BuildContext context, WidgetRef ref) async {
final notifier = ref.read(unifiedDiagnosticsProvider.notifier);
final router = GoRouter.of(context); // capture before unmount
await notifier.cancel();
await notifier.teardownDone;
router.goNamed(RouteNamed.uspDashboard);
}W-STALE-TEARDOWN [Dual-audit, 🟢High] unified_diagnostics_notifier.dart:43,47 — _teardownFuture never cleared; permanently resolves immediately after first cancel()
_teardownFuture is assigned on cancel() and goBack() preQualifying but never reset to null. Once the first teardown completes, the field holds a permanently-resolved Future<void>. Any subsequent await teardownDone during a new active run resolves immediately — false guarantee that no teardown is in flight.
Dangerous combined with W-GOBACK-RUNNING: cancel() → teardown A completes → new run starts → user goBack() from running state (teardown B unawaited, _teardownFuture untouched) → await teardownDone resolves on stale teardown A. This silently bypasses the ordering guarantee teardownDone was designed to provide.
B2 Verdict: True bug, dual-audit, in-scope → ✅ review-fix card queued.
Fix: Reset at each run entry point:
Future<void> runFullDiagnostic() async {
_teardownFuture = null; // reset stale reference before new run
...
}
// Same in startWithPreQualifier() and selectFlow()✅ What Looks Good
- W-6 fully resolved (High confidence):
goBack()preQualifying/selectFlow/manualTools branch now executes the complete 3-step teardown invariant at:305-329—++_generation, capture+null_scope, full drain+scope.release()via_teardownFuture,unawaited(). Mirrorscancel()and the running branch correctly. Regression test added. - H-4 fully resolved (High confidence):
_teardownFuturefield (:43),teardownDonegetter (:47),cancel()now assigns_teardownFuture = () async {...}()(:256-271) andunawaited(_teardownFuture!)(:271). The pattern correctly keeps UI non-blocking while making teardown observable. _returnToDashboarddouble-await (High confidence)::76-79—await notifier.cancel(); await notifier.teardownDone;restores scope-release ordering for the dashboard exit path.- New regression test: Covers
goBack()from preQualifying while scope is live with an in-flight ping. VerifiesmockScope.release()called once andteardownDoneresolves afterpingCompleter.complete(). teardownDonegetter design:_teardownFuture ?? Future.value()correctly returns immediately when no teardown is in flight — safe to unconditionallyawait.- 28/28 tests pass per author comment.
Cross-reviewed by two independent agents (security+correctness / architecture+maintainability). Automated — please sanity-check before merge.
|
|
||
| /// Resolves when the last [cancel]/[goBack] teardown (in-flight drain + | ||
| /// scope release) has completed. See [_teardownFuture]. | ||
| Future<void> get teardownDone => _teardownFuture ?? Future.value(); |
There was a problem hiding this comment.
W-STALE-TEARDOWN / W-GOBACK-RUNNING root: teardownDone correctly returns Future.value() when _teardownFuture == null, but once assigned it holds the reference forever — a permanently-resolved future after any cancel(). Combined with the running-branch goBack() below (which never assigns _teardownFuture), callers get a stale guarantee.
Fix 1 (stale): Reset _teardownFuture = null at the start of each run entry point (runFullDiagnostic, startWithPreQualifier, selectFlow).
Fix 2 (running branch): Apply _teardownFuture = () async { ... }(); unawaited(_teardownFuture!); in the default: case of goBack() (around line 355), or extract _invalidateActiveRun() shared by all three sites.
| // the unsubscribe DELETE against the next acquire's subscribe POST. | ||
| final notifier = ref.read(unifiedDiagnosticsProvider.notifier); | ||
| await notifier.cancel(); | ||
| await notifier.teardownDone; |
There was a problem hiding this comment.
W-CONTEXT-MOUNTED: cancel() synchronously resets state = const UnifiedDiagnosticsState(), triggering a rebuild that swaps DiagnosticResultsView out for DiagnosticStartView — the BuildContext here belongs to the former. By the time teardownDone resolves (1+ frames if _runFuture is non-null), context.mounted is false → navigation is silently dropped. User ends up on the idle diagnostics screen instead of uspDashboard.
Fix: Capture the router reference before the awaits:
final router = GoRouter.of(context);
await notifier.cancel();
await notifier.teardownDone;
router.goNamed(RouteNamed.uspDashboard);…v paths (#1175) - W-GOBACK-RUNNING: goBack() running (default) case now assigns _teardownFuture instead of a bare unawaited(), so teardownDone reflects this back-nav's drain + scope release (mirrors cancel() and the preQualifying case). - W-STALE-TEARDOWN: reset _teardownFuture = null at each run entry point (runFullDiagnostic/startWithPreQualifier/selectFlow) so teardownDone no longer resolves early on a stale, already-completed cancel/goBack future. - W-RETURN-MENU: _returnToMenu now awaits cancel() + teardownDone before navigating (mirrors _returnToDashboard); callers updated for async. - W-CONTEXT-MOUNTED: _returnToDashboard captures GoRouter before the awaits so navigation isn't silently dropped once cancel() unmounts the widget.
Review-fix — Round 8 findings addressed (commit
|
There was a problem hiding this comment.
🤖 Automated Review — Round 9 · 8f7dde0..1b4c825 (incremental)
Verdict: 💬 COMMENT — H-1 still unresolved: startWithPreQualifier() never assigns _runFuture; teardown drain unconditionally skips; scope.release() fires while pingInternet is live.
Standards
| Conf. | Where | Issue (one-liner) | |
|---|---|---|---|
| 🔴 [XX] | 🟢High | unified_diagnostics_notifier.dart:125-213 |
H-1: startWithPreQualifier() never assigns _runFuture — teardown drain always skips; scope.release() fires while pingInternet is live (both agents; R8→R9 carry, still unresolved) |
| 🟢High | unified_diagnostics_view.dart:56 |
W-ASYNC-VCALLBACK: onBackTap: () => _handleBack(...) passes Future<void> to VoidCallback slot — Future silently discarded; await _returnToMenu chain is effectively fire-and-forget from widget; no double-tap guard (both agents) |
|
| 🟡Med | unified_diagnostics_view.dart:117,132 |
W-DOUBLE-CANCEL: _returnToMenu reachable from AppButton.onTap (VoidCallback — Future discarded) and _handleBack; rapid double-tap races two concurrent cancel() calls, overwriting _teardownFuture mid-flight |
|
| ✅ | 🟢High | unified_diagnostics_notifier.dart:361-381 |
W-GOBACK-RUNNING resolved: goBack() default case now assigns _teardownFuture — mirrors cancel() and preQualifying case |
| ✅ | 🟢High | unified_diagnostics_notifier.dart:108,130,225 |
W-STALE-TEARDOWN resolved: _teardownFuture = null at all three run entry points — new runs correctly reflect live-or-no-teardown state |
| ✅ | 🟢High | unified_diagnostics_view.dart:136-143 |
W-RETURN-MENU resolved: _returnToMenu now async, awaits cancel() + teardownDone before navigating |
| ✅ | 🟢High | diagnostic_results_view.dart:73-80 |
W-CONTEXT-MOUNTED resolved: GoRouter captured before awaits — navigation no longer silently dropped after cancel() unmounts widget |
| 💡 | 🟢High | unified_diagnostics_notifier.dart:256,325,370 |
[DuplicatedCode] S-1: teardown invariant now quadruplicated (was triplication in R8) — extract _invalidateActiveRun(String ctx) is more urgent |
| 💡 | 🟢High | notifier_test.dart |
S-2: drain-before-release ordering assertion still absent — verifyInOrder([pingInternet, scope.release()]) not added |
| 💡 | 🟢High | unified_diagnostics_notifier.dart:35-47 |
S-3: _teardownFuture/teardownDone DartDoc not updated to document goBack() running-case teardown tracking pattern |
| 💡 | 🟢High | notifier_test.dart |
S-4: no test for cancel() while checkWanStatus still suspended (scope not yet acquired — the most dangerous H-1 scenario) |
Confidence: 🟢High = code-verified · 🟡Med = located + reasoned, not fully confirmed · ⚪Low = speculative, please double-check.
[X] = 1 agent · [XX] = 2 agents flagged independently → higher confidence (Critical only).
Spec
| Conf. | Where | Issue (one-liner) | |
|---|---|---|---|
| 🟢High | spec#"Action should stop immediately" → notifier.dart:125-213 |
Spec-W-2: startWithPreQualifier drain no-op — overlaps H-1; scope released while in-flight pingInternet live (still open) |
|
| 🟡Med | spec#nav → unified_diagnostics_view.dart:136-143 |
Spec-NEW-W-1: _returnToMenu uses context.mounted guard — nav silently dropped if widget disposed during teardown; asymmetry with _returnToDashboard (router-capture pattern) |
|
| 💡 | 🟢High | spec#"Click Cancel Diagnostics button" → unified_diagnostics_view.dart:69-100 |
Spec-S-1: No Cancel button rendered during preQualifying spinner — spec scenario only exercisable via app-bar Back (unchanged) |
| ✅ | 🟢High | spec#"Action should stop immediately" → notifier.dart:361-381 |
Spec-W-1 resolved: goBack() running-case _teardownFuture now assigned — mid-run Back stops immediately |
Linked spec: issue #1148 "Cancel Diagnostics button not action immediately".
Confidence: 🟢High = code-verified · 🟡Med = located + reasoned, not fully confirmed · ⚪Low = speculative, please double-check.
[X] = 1 agent · [XX] = 2 agents flagged independently → higher confidence (Critical only).
🔴 Critical — Evidence Chain
H-1 [XX] startWithPreQualifier() — _runFuture never assigned; teardown drain always skips; scope released while pingInternet is live
Position: unified_diagnostics_notifier.dart:125-213 (method body), :256-271 (cancel teardown), :361-381 (goBack running teardown)
Code — all other async entry points correctly track _runFuture (runFullDiagnostic ~line 108):
final future = _runFullDiagnosticFlow(gen);
_runFuture = future; // ← assigned before first await
try {
await future;
} finally {
if (identical(_runFuture, future)) _runFuture = null;
}
// selectFlow (~line 225): identical patternCode — startWithPreQualifier (~lines 128–213) — _runFuture never assigned:
Future<void> startWithPreQualifier() async {
final gen = ++_generation;
_teardownFuture = null; // ← W-STALE-TEARDOWN fix (Round 9) ✓ — does NOT fix drain gap
logger.i('[Diagnostics] Starting with pre-qualifier');
state = const UnifiedDiagnosticsState(step: DiagnosticStep.preQualifying);
try {
final wan = await svc.checkWanStatus(); // suspend A — _runFuture == null
// ...
await _ensureScope(); // scope acquired — _runFuture still null
final pingResult = await svc.pingInternet(repeatCount: 1); // suspend B — _runFuture still null
// ...
}
// NO _runFuture = ... assignment anywhere in this method
}Code — teardown drain in cancel() / goBack() running case (both at 1b4c825):
_teardownFuture = () async {
final inFlight = _runFuture; // ← always null when startWithPreQualifier is active
if (inFlight != null) { // ← always false — drain UNCONDITIONALLY SKIPPED
try { await inFlight; } catch (_) {}
}
if (scope != null) {
try {
await scope.release(); // ← fires IMMEDIATELY while pingInternet is still in-flight
} catch (e) { logger.w('...'); }
}
}();Why this is a bug: When Cancel or Back fires during startWithPreQualifier at suspend point B, _runFuture == null → drain skips → scope.release() fires the DELETE unsubscribe immediately while the pingInternet SSE/JNAP call is still live. The in-flight service call continues on a released scope (DiagnosticScope._ensureLive() throws StateError), and a quick re-entry races the DELETE against the new subscribe POST.
Why the Round 9 _teardownFuture = null fix does NOT address H-1: It resolves W-STALE-TEARDOWN (stale future identity at run entry) but the structural gap is different — startWithPreQualifier never writes _runFuture, so the drain step in every downstream teardown silently no-ops against its work regardless of when _teardownFuture was last nulled.
Trigger condition: User navigates to Network Diagnostics → taps "Choose specific issue" → startWithPreQualifier acquires scope → pingInternet awaits network → user taps Cancel or Back. Reproducible in 3 taps.
Fix (mirrors runFullDiagnostic / selectFlow):
Future<void> startWithPreQualifier() async {
final gen = ++_generation;
_teardownFuture = null;
// ...
final future = _runPreQualifierFlow(gen); // extract body into private helper
_runFuture = future;
try {
await future;
} finally {
if (identical(_runFuture, future)) _runFuture = null;
}
}⚠️ Warning Details
W-ASYNC-VCALLBACK [Dual-audit, 🟢High] unified_diagnostics_view.dart:56 — onBackTap: VoidCallback discards Future<void> from async _handleBack
_handleBack was promoted to async Future<void> in this diff. However the call site:
// unified_diagnostics_view.dart ~line 56:
onBackTap: () => _handleBack(context, ref, state),passes Future<void> to ui_kit_page_view.dart's VoidCallback? slot:
final VoidCallback? onBackTap; // void Function() — Future<void> silently discardedThe async continuation (including await notifier.cancel(); await notifier.teardownDone; in _returnToMenu) still executes on the event loop, so normal single-tap behaviour is correct. But:
- No double-tap guard: A second tap fires before the first invocation's navigation completes. Both fire
cancel()concurrently — the second call overwrites_teardownFuture, making the first teardown unobservable viateardownDone. - False confidence: The developer sees
await _returnToMenu(...)and believes the framework waits; it does not.
B2 Verdict: True bug, dual-audit, in-scope → ✅ review-fix card queued.
Fix:
// Option A — single-flight guard:
bool _isBackNavigating = false;
void _handleBack(BuildContext context, WidgetRef ref, UnifiedDiagnosticsState state) {
if (_isBackNavigating) return;
_isBackNavigating = true;
final notifier = ref.read(unifiedDiagnosticsProvider.notifier);
final handledInternally = notifier.goBack();
if (!handledInternally) {
final router = GoRouter.of(context);
notifier.cancel();
unawaited(notifier.teardownDone.then((_) {
_isBackNavigating = false;
router.canPop() ? router.pop() : router.goNamed(RouteNamed.uspMenu);
}));
} else {
_isBackNavigating = false;
}
}W-DOUBLE-CANCEL [Single-audit, 🟡Med] unified_diagnostics_view.dart:117,132 — _returnToMenu reachable from two VoidCallback paths; concurrent double-cancel possible
_returnToMenu is called from:
AppButton.onTap: () => _returnToMenu(context, ref)(~line 117) —VoidCallback?, Future discarded_handleBack → await _returnToMenu(...)(~line 132) — awaited, but outer Future discarded (see W-ASYNC-VCALLBACK)
Rapid double-tap from completed state fires two concurrent invocations. Each calls cancel() → second call overwrites _teardownFuture before first teardown completes; navigation may fire twice.
Fix: Same single-flight guard as W-ASYNC-VCALLBACK. Once _handleBack uses the guard approach, _returnToMenu only runs once per gesture.
✅ What Looks Good
- W-GOBACK-RUNNING fully resolved (High confidence):
goBack()default:(running) case at:361-381now assigns_teardownFuture = () async {...}(); unawaited(_teardownFuture!);—teardownDonecorrectly tracks this back-nav's drain + scope release. Mirrorscancel()and the preQualifying case. - W-STALE-TEARDOWN fully resolved (High confidence):
_teardownFuture = nullatrunFullDiagnostic:108,startWithPreQualifier:130,selectFlow:225— new runs seeteardownDone → Future.value()until a real teardown starts; stale prior teardown no longer visible. - W-RETURN-MENU fully resolved (High confidence):
_returnToMenuis nowasync, doesawait notifier.cancel(); await notifier.teardownDone; if (!context.mounted) return;— closes the unsubscribe-DELETE vs subscribe-POST race on quick re-entry. Mirrors_returnToDashboard. - W-CONTEXT-MOUNTED fully resolved (High confidence):
_returnToDashboardcapturesfinal router = GoRouter.of(context)before the awaits — navigation survives widget unmount caused bycancel()'s synchronous state reset. _handleBackcaller updated correctly:_handleBackproperlyawaits_returnToMenu(~line 132) — the intent and logic are right; only the VoidCallback boundary (W-ASYNC-VCALLBACK) prevents the outer await from being effective from the framework's perspective.
Cross-reviewed by two independent agents (security+correctness / architecture+maintainability). Automated — please sanity-check before merge.
…sync (#1175) _handleBack was async, so its Future<void> was silently discarded by the VoidCallback onBackTap slot — a second back-tap could fire a concurrent cancel()/teardown that clobbers _teardownFuture, making the first teardown unobservable via teardownDone. Make _handleBack synchronous, add an _isBackNavigating single-flight guard, capture the router before any state change, and navigate only after teardownDone completes.
Round 9 review-fix — W-ASYNC-VCALLBACKLanded on Finding (quoted)
Fix
bool _isBackNavigating = false;
void _handleBack(BuildContext context, WidgetRef ref, UnifiedDiagnosticsState state) {
if (_isBackNavigating) return;
final notifier = ref.read(unifiedDiagnosticsProvider.notifier);
final handledInternally = notifier.goBack();
if (handledInternally) return;
_isBackNavigating = true;
final router = GoRouter.of(context); // capture before any state change
notifier.cancel();
unawaited(notifier.teardownDone.then((_) {
_isBackNavigating = false;
if (!mounted) return;
if (router.canPop()) {
router.pop();
} else {
router.goNamed(RouteNamed.uspMenu);
}
}));
}Why this is correct
Verification
|
PeterJhongLinksys
left a comment
There was a problem hiding this comment.
🤖 Automated Review — Round 6 · 82453ed..2fce6f5 (incremental)
Verdict: 💬 Comment — All five fixes in this round land in the right direction (preQualifying scope leak now closed with a regression test, _returnToMenu awaits teardown, goBack() default branch tracked, router captured before state change, handler kept sync), but the Round 4/5 Critical is still open and this round's new code now depends on it: startWithPreQualifier() never assigns _runFuture, so the drain inside the brand-new preQualifying teardown is a permanent no-op.
| Conf. | Where | Issue (one-liner) | |
|---|---|---|---|
🔴 [XX] |
🟢 | providers/unified_diagnostics_notifier.dart:128-219, :323-337 |
startWithPreQualifier() still never assigns _runFuture → the new preQualifying teardown's await inFlight is always skipped, so scope.release() runs while pingInternet is in flight and teardownDone resolves early (prior H-1, unresolved). |
[XX] |
🟢 | providers/unified_diagnostics_notifier.dart:108, :132, :227 |
_teardownFuture = null at run entry is unconditional — it also discards a still-running teardown, so teardownDone falls back to Future.value() and reports completion that has not happened (same class of false guarantee as W-STALE-TEARDOWN, direction reversed). |
[XX] |
🟢 | providers/unified_diagnostics_notifier.dart:265-280, :323-338, :369-384 |
[Duplicated Code / Shotgun Surgery] The 16-line teardown closure is now copied three times (plus a 4th variant in ref.onDispose); the _invalidateActiveRun() extraction you proposed yourself in review was not done. |
[XX] |
🟢 | views/unified_diagnostics_view.dart:138, :148-159 |
_isBackNavigating is cleared only when teardownDone resolves — with a hung run that is up to ~120 s of a silently dead Back button (no setState, no spinner, no disabled state, no timeout); .then() without onError can latch it permanently. |
[XX] |
🟢 | views/unified_diagnostics_view.dart:119, :162-176; views/widgets/diagnostic_results_view.dart:48 |
[Divergent Change] The VoidCallback/single-flight fix was applied to _handleBack only; _returnToMenu and onDone still drop their Future<void> into a VoidCallback slot with no guard, and the two "leave page" paths in one State class now use two different orchestration models. |
[X] |
🟢 | providers/unified_diagnostics_notifier.dart:7, :91-96, :274, :332, :378 |
Art. XIII: the provider talks to NetworkDiagnosticsExecutor directly and does its own raw catch (e) (now 4 sites), while the purpose-built DiagnosticsScopeService (services/diagnostics_scope_service.dart:46-63, 20 tests) has zero call sites. |
[X] |
🟢 | providers/unified_diagnostics_notifier.dart:96-99 |
_ensureScope() assigns _scope/attachScope after await acquireScope() with no _isCurrent(gen) check → a Back during acquisition installs an ownerless scope after teardown already reported done (this round widened the window). |
[X] |
🟢 | providers/unified_diagnostics_notifier.dart:167-170 |
No _isCurrent(gen) between _ensureScope() and pingInternet() → one USP Operate still goes out after Cancel/Back (StateError swallowed by the outer catch (e)); sibling runners guard immediately after _ensureScope(). |
[X] |
🟢 | views/widgets/diagnostic_results_view.dart:81-85 |
Capturing the router is correct, but removing context.mounted leaves an unconditional router.goNamed() after two awaits with no liveness/one-shot guard; _returnToMenu:170 still uses context.mounted → two guard models side by side. |
[X] |
🟡 | dashboard/mascot/mascot_providers.dart:172 |
The cancel→teardownDone protocol is now honoured at 3 of 6 cancel() call sites; the cross-feature mascot site still fires cancel() alone, and nothing enforces the contract. |
💡 [XX] |
🟢 | test/.../unified_diagnostics_notifier_test.dart:1139-1147 |
The new regression test asserts release() after manually completing the ping, so it passes even if release happens before the in-flight op — i.e. it cannot detect the Critical above. Prefer verifyNever(release) → complete → verify(release).called(1). |
💡 [X] |
🟢 | test/.../unified_diagnostics_notifier_test.dart |
3 of the 4 fixes this round ship with no test: the _teardownFuture = null change, the goBack() default-branch tracking, and _isBackNavigating. |
💡 [X] |
🟢 | providers/unified_diagnostics_notifier.dart:280, :338, :384 |
unawaited(_teardownFuture!) force-unwraps a field just assigned — use a local (also a prerequisite for self-identifying reset). |
💡 [X] |
🟢 | views/unified_diagnostics_view.dart:136 |
_handleBack(..., UnifiedDiagnosticsState state) never reads state; the call site at :58 passes it for nothing. |
💡 [XX] |
🟢 | providers/unified_diagnostics_notifier.dart:315-320; views/unified_diagnostics_view.dart:146; views/widgets/diagnostic_results_view.dart:75 |
Comments overclaim: the preQualifying comment advertises a drain that is currently a no-op, and the "unsubscribe DELETE vs subscribe POST" wording (now in 3 files) does not match SseOperationAwaiter.endSharedSession(), which only decrements a refcount and starts a 4 s linger timer. |
💡 [X] |
🟡 | providers/unified_diagnostics_notifier.dart:341-355 |
The showingResults/completed branch is the only goBack() case that applies none of the 3-step invariant; when flow == null it resets to idle while leaving _scope alive and _teardownFuture untouched. Please state the intent ("scope deliberately kept for restart") in a comment. |
💡 [X] |
🟢 | providers/unified_diagnostics_notifier.dart:106-107, :130-131, :225-226 |
The same 2-line comment is copied 3×; it disappears together with the code if the reset moves into the closure's finally. |
💡 [X] |
🟢 | test/.../unified_diagnostics_notifier_test.dart:1091-1116 |
Inline WanStatusUIModel literals instead of a Test Data Builder (test/mocks/test_data/ has 4 builders, none for diagnostics); the 11-line preamble comment duplicates the PR description. |
Confidence: 🟢 High = read head code, evidence quoted · 🟡 Med = file:line + reasoning, not fully proven · ⚪ Low = speculative.
[X] = raised by 1 agent · [XX] = raised independently by both agents.
🔴 Critical — evidence chain
C-1 · startWithPreQualifier() never assigns _runFuture → the new preQualifying drain is a no-op
1. Location. lib/page/unified_diagnostics/providers/unified_diagnostics_notifier.dart:128-219 (runner) and :323-337 (this round's new teardown).
2. Code (head 2fce6f53). Every _runFuture occurrence in the file:
33: Future<void>? _runFuture;
112: _runFuture = future; // runFullDiagnostic
116: if (identical(_runFuture, future)) _runFuture = null;
242: _runFuture = future; // selectFlow
246: if (identical(_runFuture, future)) _runFuture = null;
266: final inFlight = _runFuture; // cancel() teardown
324: final inFlight = _runFuture; // goBack() preQualifying teardown (NEW)
370: final inFlight = _runFuture; // goBack() running teardown
startWithPreQualifier() (:128-219) assigns nothing — yet it does acquire and use the scope:
167: try {
168: await _ensureScope();
169: final pingResult = await svc.pingInternet(repeatCount: 1);
170: if (!_isCurrent(gen)) return;So the new teardown reads null and skips straight to release:
323: _teardownFuture = () async {
324: final inFlight = _runFuture; // null during preQualifying
325: if (inFlight != null) { ... } // skipped
330: if (scope != null) {
332: await scope.release(); // released while pingInternet is in flight3. Downstream effect (each hop verified). network_diagnostics_executor.dart:391-397: release() → _awaiter.endSharedSession(), and _ensureLive() throws StateError afterwards. sse_operation_awaiter.dart:236-251: refcount 1→0 → 4 s linger timer → _runTeardown() removes the shared cleanups. The in-flight executeInSession stays parked on completer.future.timeout(30s) and can no longer receive OperationComplete → orphaned USP Operate that only ends on the 30 s timeout.
4. Trigger. Start screen → "Start" (diagnostic_start_view.dart:21 → startWithPreQualifier) → WAN Up with IP → runner parked at :169 with step == preQualifying → user taps app-bar Back → goBack() takes the :305 branch → _runFuture == null → drain skipped → immediate release(). Consequences: (a) orphaned Operate for 30 s; (b) shared SSE subscription torn down 4 s later while an op is live; (c) teardownDone resolves almost immediately, so the "navigate only after full teardown" guarantee added in _handleBack:151 does not hold on this path — the quick-re-entry race this PR targets is still open for preQualifying.
5. Fix (mirrors the other two entry points, <8 lines).
Future<void> startWithPreQualifier() async {
final gen = ++_generation;
final future = _preQualifierFlow(gen); // extract current body
_runFuture = future;
try { await future; } finally { if (identical(_runFuture, future)) _runFuture = null; }
}Then make the new test order-sensitive: verifyNever(() => mockScope.release()) before pingCompleter.complete(...), verify(...).called(1) after. As written (test:1139-1147) the assertion passes either way, which is why "28/28 green" did not surface this.
⚠️ Warnings — details
W-1 · _teardownFuture = null discards in-flight teardowns (notifier.dart:108, :132, :227)
The comment claims it clears a completed teardown, but the assignment is unconditional and nothing checks completion. Sequence: Cancel during a long traceroute (120 s timeout) → cancel() creates teardown A, parked on await inFlight, release() not yet run → user immediately hits Start/Restart → runFullDiagnostic()/selectFlow() nulls the field → teardownDone (:47) falls through to Future.value() and resolves in the same microtask → the next await notifier.teardownDone (view:169, results_view:84) passes while A's release() may still land after the next acquireScope(). Additionally cancel()/goBack() overwrite _teardownFuture without chaining the previous one, so two consecutive cancel() calls make the second one resolve instantly. Fix: keep the reset inside the closure, matching the existing _runFuture idiom (:116, :246):
late final Future<void> t;
t = () async {
try { /* drain + release */ }
finally { if (identical(_teardownFuture, t)) _teardownFuture = null; }
}();
_teardownFuture = t;
unawaited(t);This also lets the three entry-point resets and their duplicated comments disappear. Practical severity is bounded by the awaiter's refcount + 4 s linger, hence Warning rather than Critical — but the documented guarantee on teardownDone (:45-46) is still not true.
W-2 · Teardown closure now copied 3× (author's own _invalidateActiveRun() proposal not applied) (:265-280, :323-338, :369-384)
The three closures are byte-identical apart from the log string, and the ++_generation; final scope = _scope; _scope = null; preamble is repeated 3× as well. This is the 3rd consecutive round where the finding is "another call site was missed", and the reason the call sites keep being missed is that there is no single exit. Extracting _invalidateActiveRun({required bool releaseScope, String reason}) removes ~35 lines and simultaneously fixes W-1 and the force-unwraps.
W-3 · Back button can be silently dead for ~120 s (view.dart:131, :138, :148-159)
_isBackNavigating is only cleared inside teardownDone.then(...), and teardownDone contains await inFlight (traceroute 120 s / ping 30 s). During that window :138 swallows every further Back tap, and because the flag is a plain field with no setState there is no spinner, no disabled state and no timeout — the user just sees a broken Back. .then((_) {...}) also has no onError; today the closure catches both throw sites (:326-328, :331-335) so it cannot reject, but one unguarded line added later latches the flag forever and the navigation never fires. Prefer .timeout(const Duration(seconds: 3), onTimeout: () {}) plus whenComplete (or try/finally) and drive a visible loading state.
W-4 · Single-flight/VoidCallback fix applied to only one of three exit paths (view.dart:119, :162-176; results_view.dart:48, :64)
AppButton.onTap and UiKitPageView.onBackTap are both VoidCallback?, so onTap: () => _returnToMenu(context, ref) and onDone: () => _returnToDashboard(context, ref) still discard a Future<void> with no guard — exactly the reasoning used to justify the _handleBack rewrite. Double-tapping "Done" runs two cancel()s (second overwrites _teardownFuture, see W-1) and two goNameds. The two paths in _UnifiedDiagnosticsViewState also now differ on all four axes (guard / router capture / mounted vs context.mounted / await style) while implementing the same semantics. Suggest one _leaveDiagnostics({required String fallbackRoute}) sharing a single _isLeaving flag.
W-5 · Provider bypasses DiagnosticsScopeService and does its own raw catch (e) (notifier.dart:7, :91-96, :274, :332, :378; Art. XIII / Art. V §5.4)
services/diagnostics_scope_service.dart:46-63 already wraps acquireScope/releaseScope and maps errors via mapUspErrorToServiceError, with a DartDoc explicitly citing Article XIII — and it has zero call sites. The notifier instead imports the executor directly and swallows raw errors in 4 places (this round added the 3rd and 4th). Pre-existing, but widened here; routing the new helper through the service brings the raw-catch count to zero and gives those 20 existing service tests a purpose.
W-6 · _ensureScope() has no generation check after its await (notifier.dart:96-99)
96: final scope = await executor.acquireScope();
97: _scope = scope;
98: _svc?.attachScope(scope);If Back happens while acquireScope() is pending, goBack() (:321-322) sees _scope == null, releases nothing, and teardownDone reports completion; the revived runner then installs a scope nobody owns (released only at notifier dispose, :71-81). The capture-then-null pattern added this round makes this window materially worse. Fix: _ensureScope(int gen) and if (!_isCurrent(gen)) { await scope.release(); return; }.
W-7 · Missing guard before pingInternet (notifier.dart:167-170)
:170's guard fires after the Operate is sent. If the scope was already released, DiagnosticScope.ping()'s _ensureLive() throws StateError, swallowed by the outer catch (e) at :201. Harmless but noisy; sibling runners (_runFullDiagnosticFlow:438) guard immediately after _ensureScope().
W-8 · Unconditional router.goNamed() (results_view.dart:81-85)
Capturing the router is the right diagnosis (this widget is swapped out by _buildContent:71-79), but with the guard removed entirely, any other navigation that interleaves during the two awaits (session expiry redirect, browser back) gets overridden. No reproducible interleaving was proven inside this PR's scope, so this is flagged as a defensive gap (🟡), not a user-triggerable bug — please still unify with _returnToMenu:170, which retains context.mounted.
W-9 · cancel/teardown contract honoured at 3 of 6 call sites (mascot_providers.dart:172)
teardownDone is a public getter with nothing enforcing "await after cancel()". Making Future<void> cancelAndAwaitTeardown() the only exit API and privatising teardownDone turns this into a compile-time guarantee.
✅ What looks good
- W-6 (preQualifying scope leak) is the one fix that ships with a regression test, and
:315-320accurately explains why_scopemust be nulled (so_ensureScope()'sexisting != null && !existing.isReleasedcannot reuse a scope with a live op). The test also documents why the pre-existing "does not clobber idle screen" test could not cover this path — good test reasoning. - W-CONTEXT-MOUNTED was fixed in exactly the right file. Of the three call sites, only
diagnostic_results_view.dartowns a context thatcancel()'s rebuild unmounts; the fix was not blindly copied to the other two. - W-ASYNC-VCALLBACK took the right trade-off: keep the handler synchronous with an explicit flag instead of changing
UiKitPageView.onBackTapto anAsyncCallback(which would alter a shared ui_kit contract, Art. XV). - The
_handleBackordering is subtly correct:cancel()'s body has noawait, so it runs synchronously to completion and_teardownFutureis already assigned when:151readsteardownDone— that is easy to get wrong. - Error containment in the teardown closures is complete (
catch (_)on the drain,catch (e) + logger.won release), so neitherunawaited(...)nor the View'sawait teardownDonecan propagate an exception into navigation. - Layering and USP rules are clean this round:
generated/imports appear only underunified_diagnostics/services/(0 inproviders/, 0 inviews/); dependencies are one-way View→Provider; no mutation (souspMutationLockProviderN/A), no new L1 provider, noPreservableContractredefinition; noon Exception catchanywhere inlib/core/usp/orlib/page/unified_diagnostics/— the service consistently usescatch (e) { throw mapUspErrorToServiceError(e); }. - Security surface clean: no hardcoded secrets/tokens in the 155 changed lines, no
flutter_secure_storagechanges, no new user-input handling, navigation targets areRouteNamedconstants only. - Scope discipline: 4 files / 155 lines, all on-topic, no drive-by refactors or lint churn.
Note: no Dart/Flutter toolchain in the review sandbox, so the reported dart analyze / dart format / 28-passing-tests results could not be independently reproduced (nothing in the diff contradicts them).
Cross-reviewed by independent agents (security+correctness / architecture+maintainability). Automated — please sanity-check before merge.
| final gen = ++_generation; | ||
| // Clear any completed prior teardown so [teardownDone] reflects THIS run's | ||
| // lifecycle, not a stale cancel()/goBack() future that already resolved. | ||
| _teardownFuture = null; |
There was a problem hiding this comment.
🔴 Critical [XX] 🟢High — This run entry point still never assigns _runFuture. Every other runner does (:112 in runFullDiagnostic, :242 in selectFlow), so the drain added this round in the preQualifying branch of goBack() (:324 final inFlight = _runFuture;) always reads null and jumps straight to scope.release() — while pingInternet (:169) is still in flight. Result: an orphaned USP Operate that only ends on its 30s timeout, the shared SSE subscription torn down 4s later (sse_operation_awaiter.dart:236-251) under a live op, and teardownDone resolving almost immediately so the new "navigate only after teardown" guarantee in _handleBack does not hold on this path.
Fix, mirroring the other two entry points:
final future = _preQualifierFlow(gen);
_runFuture = future;
try { await future; } finally { if (identical(_runFuture, future)) _runFuture = null; }Also note the new regression test (test:1139-1147) completes the ping before asserting release(), so it passes whether or not the drain works — please make it order-sensitive (verifyNever → complete → verify).
| final gen = ++_generation; | ||
| // Clear any completed prior teardown so [teardownDone] reflects THIS run's | ||
| // lifecycle, not a stale cancel()/goBack() future that already resolved. | ||
| _teardownFuture = null; |
There was a problem hiding this comment.
await inFlight (traceroute: 120s), starting a new run drops the reference, teardownDone falls back to Future.value() at :47 and resolves in the same microtask — so await notifier.teardownDone in the views passes while the old release() may still land after the next acquireScope(). Same class of false guarantee as the stale-teardown issue, direction reversed.
Prefer the self-identifying reset already used for _runFuture (:116, :246), inside the closure:
late final Future<void> t;
t = () async { try { /* drain + release */ } finally { if (identical(_teardownFuture, t)) _teardownFuture = null; } }();
_teardownFuture = t;That removes all three entry-point resets and their duplicated comments. While there: overwriting _teardownFuture in cancel()/goBack() without chaining the previous one means a second consecutive cancel() resolves instantly.
AustinChangLinksys
left a comment
There was a problem hiding this comment.
🤖 Automated Review — Round 10 · 1b4c825..2fce6f5 (incremental)
Verdict: 💬 COMMENT — H-1 still unresolved in notifier.dart (not in this diff); new Warning W-GUARD-RESET introduced by this diff.
Standards
| Conf. | Where | Issue (one-liner) | |
|---|---|---|---|
| 🔴 [XX] | 🟢High | unified_diagnostics_notifier.dart:125-213 |
H-1: startWithPreQualifier() never assigns _runFuture — drain always skips; scope released while pingInternet live (R8→R10 carry, not in this diff) |
| 🟢High | unified_diagnostics_view.dart:151-159 |
W-GUARD-RESET: teardownDone.then() has no .catchError — _isBackNavigating permanently stuck if teardown errors; back button silently disabled (dual-audit) |
|
| 🟡Med | unified_diagnostics_view.dart:119 |
W-RETURN-MENU-GUARD: _returnToMenu via AppButton.onTap: VoidCallback? has no double-tap guard — rapid taps can double-navigate; AppButton has no internal debounce (pre-existing, not introduced by this diff) |
|
| 💡 | 🟢High | unified_diagnostics_view.dart:136 |
S-DEAD-PARAM: state parameter accepted but never read in _handleBack body |
| 💡 | 🟢High | unified_diagnostics_view.dart:148-159,162-176 |
[Duplicated Code] S-TEARDOWN-DUP: teardown+navigate pattern repeated verbatim across _handleBack and _returnToMenu — extract _teardownAndNavigate(GoRouter) |
| 💡 | 🟢High | unified_diagnostics_view.dart:149,154-157 |
S-API-STYLE: GoRouter.of(context).* in _handleBack vs context.* extension in _returnToMenu — inconsistent navigation API |
| 💡 | 🟢High | test/page/unified_diagnostics/ |
S-TEST-GUARD: no widget test for _isBackNavigating single-flight guard behavior |
Confidence: 🟢High = code-verified · 🟡Med = located + reasoned, not fully confirmed · ⚪Low = speculative, please double-check.
[X] = 1 agent · [XX] = 2 agents flagged independently → higher confidence (Critical only).
Spec
| Conf. | Where | Issue (one-liner) | |
|---|---|---|---|
| 🟢High | spec#"Action should stop immediately" → unified_diagnostics_notifier.dart:125-213 |
Spec-W-2: H-1 overlap — startWithPreQualifier drain no-op; "stop immediately" guarantee not met for preQualifying-path Cancel (carry-over) |
|
| 🟡Med | spec#nav → unified_diagnostics_view.dart:170 |
Spec-R9-W-1: _returnToMenu uses context.mounted guard — nav silently dropped if widget disposed during teardown; _handleBack uses mounted correctly (carry-over) |
|
| 🟢High | spec#step3:"Click Cancel Diagnostics" → diagnostic_running_view.dart:96 |
Spec-NEW-W-1: R10 diff fixes app-bar Back path only; spec primary scenario (Cancel button) is a separate path not changed here; spec satisfied by prior rounds' cancel() |
|
| 💡 | 🟡Med | spec#nav → diagnostic_running_view.dart:96 vs unified_diagnostics_view.dart:148-159 |
Spec-S-2: Cancel button cancels without navigating (resets to idle, stays on page); Back button navigates away — asymmetric UX for the same user goal |
| 💡 | 🟢High | spec#"Click Cancel Diagnostics" → unified_diagnostics_view.dart:69-100 |
Spec-S-1: No Cancel button rendered during preQualifying spinner — spec scenario exercisable only via app-bar Back (carry-over) |
Linked spec: issue #1148 "Cancel Diagnostics button not action immediately".
Confidence: 🟢High = code-verified · 🟡Med = located + reasoned, not fully confirmed · ⚪Low = speculative, please double-check.
[X] = 1 agent · [XX] = 2 agents flagged independently → higher confidence (Critical only).
🔴 Critical — Evidence Chain
H-1 [XX] startWithPreQualifier() — _runFuture never assigned; teardown drain always skips; scope released while pingInternet is live
Status: Carry-over from R8→R10. unified_diagnostics_notifier.dart was NOT changed in this Round 10 commit. This item remains open and blocks merge.
Position: unified_diagnostics_notifier.dart:125-213 (method body), :256-271 (cancel teardown)
Code — all other entry points correctly track _runFuture:
// runFullDiagnostic ~line 108
final future = _runFullDiagnosticFlow(gen);
_runFuture = future; // ← assigned before first await
try {
await future;
} finally {
if (identical(_runFuture, future)) _runFuture = null;
}
// selectFlow (~line 225): identical patternCode — startWithPreQualifier (~lines 128–213) — _runFuture never assigned:
Future<void> startWithPreQualifier() async {
final gen = ++_generation;
_teardownFuture = null; // ← W-STALE-TEARDOWN fix (Round 9) ✓
logger.i('[Diagnostics] Starting with pre-qualifier');
state = const UnifiedDiagnosticsState(step: DiagnosticStep.preQualifying);
try {
final wan = await svc.checkWanStatus(); // suspend A — _runFuture == null
// ...
await _ensureScope(); // scope acquired — _runFuture still null
final pingResult = await svc.pingInternet(repeatCount: 1); // suspend B — _runFuture still null
// ...
}
// NO _runFuture = ... assignment anywhere in this method
}Code — teardown drain in cancel() (head 2fce6f5):
_teardownFuture = () async {
final inFlight = _runFuture; // ← always null when startWithPreQualifier is active
if (inFlight != null) { // ← always false — drain UNCONDITIONALLY SKIPPED
try { await inFlight; } catch (_) {}
}
if (scope != null) {
try {
await scope.release(); // ← fires IMMEDIATELY while pingInternet is still in-flight
} catch (e) { logger.w('...'); }
}
}();Why this is a bug: When Cancel or Back fires during startWithPreQualifier at suspend B, _runFuture == null → drain skips → scope.release() fires immediately while pingInternet SSE/JNAP call is still live. The in-flight call continues on a released scope; quick re-entry races the unsubscribe DELETE against the new subscribe POST.
Trigger condition: User → Network Diagnostics → "Choose specific issue" → startWithPreQualifier starts → pingInternet awaits network → user taps Cancel or Back. Reproducible in 3 taps.
Fix (mirrors runFullDiagnostic / selectFlow):
Future<void> startWithPreQualifier() async {
final gen = ++_generation;
_teardownFuture = null;
// ...
final future = _runPreQualifierFlow(gen); // extract body into private helper
_runFuture = future;
try {
await future;
} finally {
if (identical(_runFuture, future)) _runFuture = null;
}
}⚠️ Warning Details
W-GUARD-RESET [Dual-audit, 🟢High] unified_diagnostics_view.dart:151-159 — _isBackNavigating not reset on teardownDone error completion
Introduced by this Round 10 commit. The single-flight guard is set at line 148 but only reset inside .then():
// lines 148–159 (head 2fce6f5)
_isBackNavigating = true;
final router = GoRouter.of(context);
notifier.cancel();
unawaited(notifier.teardownDone.then((_) {
_isBackNavigating = false; // ← only runs on success
if (!mounted) return;
if (router.canPop()) { router.pop(); } else { router.goNamed(RouteNamed.uspMenu); }
}));
// ← no .catchError / .whenComplete — error path leaves _isBackNavigating = true foreverCurrent teardown IIFE fully wraps all ops in try/catch (near-zero error probability today), but defensive contract is missing. If teardownDone ever completes with an error, .then() is skipped, _isBackNavigating is permanently true, and the app-bar back button is silently dead for the remaining widget lifetime.
B2 Verdict: True bug (dual-audit), introduced by this diff, in-scope → ✅ review-fix card queued.
Fix:
unawaited(
notifier.teardownDone
.whenComplete(() => _isBackNavigating = false)
.then((_) {
if (!mounted) return;
if (router.canPop()) { router.pop(); } else { router.goNamed(RouteNamed.uspMenu); }
}),
);W-RETURN-MENU-GUARD [Single-audit, 🟡Med] unified_diagnostics_view.dart:119 — _returnToMenu has no double-tap guard (pre-existing)
AppButton.onTap: () => _returnToMenu(context, ref) is a VoidCallback? slot. Verified in app_button.dart:383: AppButton only guards on isLoading (must be set explicitly by caller); no automatic debounce on tap. Rapid double-tap on "Return to Dashboard" from completed state can launch two concurrent async teardown+navigate chains. cancel() is idempotent, but two concurrent await teardownDone + context.pop() calls may double-pop the navigation stack.
B2 Verdict: Pre-existing (not introduced by this diff — _returnToMenu and its AppButton caller predate Round 10) → ❌ comment-only, no review-fix card. Suggested fix: add _isNavigatingAway guard mirroring _isBackNavigating, or unify both paths into a shared _teardownAndNavigate(GoRouter) helper (see S-TEARDOWN-DUP).
✅ What Looks Good
- W-ASYNC-VCALLBACK fully resolved (🟢High):
_handleBackis nowvoid, correctly fits theVoidCallback?onBackTapslot — no more silentFuture<void>discard. - W-DOUBLE-CANCEL fully resolved (🟢High):
_isBackNavigatingsingle-flight guard prevents concurrent cancel()/teardown from a rapid double-tap on the app-bar back button. - Router pre-capture correct (🟢High):
GoRouter.of(context)captured beforenotifier.cancel()mutates state — navigation survives widget unmount caused by synchronous state reset. mountedcheck in teardown callback (🟢High):if (!mounted) returnin the.then()callback correctly guards navigation when widget is disposed before teardown completes._isBackNavigatingreset before navigation (🟢High): Flag is reset at the start of.then()callback, beforerouter.pop()/router.goNamed()— ensures flag is clear even if navigation itself throws.goBack()handled-internally path is clean (🟢High): Earlyreturnbefore_isBackNavigating = truewhengoBack()returns true — guard is not unnecessarily locked for internally-handled back steps.dart:asyncimport correct (🟢High): Required forunawaited(), correctly added.
Cross-reviewed by two independent agents (security+correctness / architecture+maintainability) + spec compliance agent. Automated — please sanity-check before merge.
) W-GUARD-RESET: _isBackNavigating was only reset inside teardownDone.then() (success path). If teardownDone ever completed with an error the guard would stay true permanently, silently disabling the app-bar back button for the widget's lifetime. Switch to .whenComplete() so the guard is released unconditionally regardless of success or error.
|
🔧 Review-fix (Round 10) — W-GUARD-RESET —
|
AustinChangLinksys
left a comment
There was a problem hiding this comment.
🤖 Automated Review — Round 10 · 1b4c825..8f9daeb (incremental)
Verdict: 💬 COMMENT — H-1 carry-over Critical unresolved in notifier (not in this diff); this round correctly fixes W-GUARD-RESET but cannot be approved while H-1 blocks.
Standards
| Conf. | Where | Issue (one-liner) | |
|---|---|---|---|
| 🔴 [XX] | 🟢High | unified_diagnostics_notifier.dart:128-220 |
H-1: startWithPreQualifier() never assigns _runFuture — drain always skips; scope released while pingInternet live (carry-over R8→R11, notifier not in this diff) |
| 🟡Med | unified_diagnostics_view.dart:157 |
W-TEAR-CHAIN-NAV: .then() navigation not unconditional — if teardownDone completes with error, guard resets but navigation silently dropped (🆕 this round; Reviewer A: Suggestion, Reviewer B: Warning) |
|
| 🟢High | test/.../unified_diagnostics_notifier_test.dart:1139-1148 |
W-TEST-ORDER-INSENSITIVE: regression test completes ping before asserting scope.release() — passes even if drain is skipped (carry-over; Peter Jhong R10 also flagged) |
|
| 🟡Med | unified_diagnostics_notifier.dart:265, 323, 369 |
W-TEARDOWN-OVERWRITE: _teardownFuture unconditionally overwritten; prior in-flight teardown orphaned → teardownDone resolves early (carry-over; pre-existing) |
|
| 🟡Med | unified_diagnostics_view.dart:168 |
W-RETURN-MENU-GUARD: _returnToMenu has no double-tap guard — concurrent cancel()+teardown chains possible (carry-over; pre-existing) |
|
| 💡 | 🟢High | unified_diagnostics_view.dart:141 |
S-DEAD-PARAM: state parameter in _handleBack never read in body (carry-over) |
| 💡 | 🟢High | unified_diagnostics_view.dart:148-165 vs 168-181 |
[Duplicated Code] S-TEARDOWN-DUP: canPop→pop/goNamed pattern repeated verbatim in both _handleBack and _returnToMenu (carry-over) |
| 💡 | 🟢High | unified_diagnostics_view.dart:149 vs 177 |
S-API-STYLE: GoRouter.of(context).* in _handleBack vs context.* extension in _returnToMenu — inconsistent navigation API (carry-over) |
| 💡 | 🟢High | test/page/unified_diagnostics/ |
S-TEST-GUARD: no widget test for _isBackNavigating single-flight guard behavior (carry-over) |
Confidence: 🟢High = code-verified · 🟡Med = located + reasoned, not fully confirmed · ⚪Low = speculative, please double-check.
[X] = 1 agent · [XX] = 2 agents flagged independently → higher confidence (Critical only).
Spec
| Conf. | Where | Issue (one-liner) | |
|---|---|---|---|
| 🔴 [XX] | 🟢High | spec#"stop immediately" → unified_diagnostics_notifier.dart:128-220 |
H-1 overlap: startWithPreQualifier drain no-op; "stop immediately" spec guarantee not met for preQualifying-path Cancel (carry-over) |
| 🟢High | spec#"stop immediately" → notifier.dart:265-278 |
Spec-W-2: pingInternet backend op continues until timeout even after cancel — scope released while in-flight (carry-over) |
|
| 🟡Med | spec#nav → unified_diagnostics_view.dart:168-179 |
Spec-R9-W-1: _returnToMenu uses context.mounted — nav silently dropped if widget disposed during teardown; _handleBack pre-captures router correctly (carry-over) |
|
| 💡 | 🟡Med | spec#Cancel button → unified_diagnostics_view.dart:88-101 |
Spec-S-1: no Cancel button rendered during preQualifying spinner — spec says Cancel should appear while diagnostics ongoing (carry-over) |
| 💡 | 🟡Med | spec#UX → diagnostic_running_view.dart:96 vs view:148-165 |
Spec-S-2: Cancel stays on page (resets to idle); Back navigates away — asymmetric UX for same user goal (carry-over) |
Linked spec: issue #1148 "Cancel Diagnostics button not action immediately".
Confidence: 🟢High = code-verified · 🟡Med = located + reasoned, not fully confirmed · ⚪Low = speculative, please double-check.
[X] = 1 agent · [XX] = 2 agents flagged independently → higher confidence (Critical only).
🔴 Critical — Evidence Chain
H-1 [XX] startWithPreQualifier() — _runFuture never assigned; drain always skips; scope released while pingInternet in flight
Status: Carry-over from R8→R11. unified_diagnostics_notifier.dart was NOT changed in this Round 11 commit. This item remains open and blocks merge.
Position: unified_diagnostics_notifier.dart:128-220 (method body), :265-278 (cancel teardown), :323-337 (goBack preQualifying teardown)
Code — all other entry points correctly track _runFuture:
// runFullDiagnostic ~line 108
final future = _runFullDiagnosticFlow(gen);
_runFuture = future; // ← assigned
try { await future; } finally { if (identical(_runFuture, future)) _runFuture = null; }
// selectFlow ~line 242: identical pattern
_runFuture = future;Code — startWithPreQualifier (lines 128–220) — _runFuture NEVER assigned (0 occurrences):
Future<void> startWithPreQualifier() async {
final gen = ++_generation;
_teardownFuture = null;
state = const UnifiedDiagnosticsState(step: DiagnosticStep.preQualifying);
try {
final wan = await svc.checkWanStatus(); // suspend A
// ...
await _ensureScope(); // scope acquired — _runFuture still null
final pingResult = await svc.pingInternet(repeatCount: 1); // suspend B — _runFuture still null
// NO _runFuture = ... anywhere in this method
}
}Code — cancel() teardown (lines 265-278):
_teardownFuture = () async {
final inFlight = _runFuture; // ← always null when startWithPreQualifier active
if (inFlight != null) { // ← always false — drain UNCONDITIONALLY SKIPPED
try { await inFlight; } catch (_) {}
}
if (scope != null) {
await scope.release(); // ← fires IMMEDIATELY while pingInternet still in-flight
}
}();goBack() preQualifying branch (lines 323-337): Identical drain pattern — same bug.
Why this is a bug: Cancel or Back during startWithPreQualifier at suspend B → _runFuture == null → drain skips → scope.release() fires immediately while pingInternet SSE/JNAP call still live. Orphaned op continues on released scope; quick re-entry races DELETE against new subscribe POST.
Trigger: User → Network Diagnostics → "Choose specific issue" → startWithPreQualifier → pingInternet awaiting network → user taps Cancel or Back. Reproducible in 3 taps.
Fix (mirrors runFullDiagnostic / selectFlow):
Future<void> startWithPreQualifier() async {
final gen = ++_generation;
_teardownFuture = null;
state = const UnifiedDiagnosticsState(step: DiagnosticStep.preQualifying);
final future = _runPreQualifierFlow(gen); // extract body into private helper
_runFuture = future;
try {
await future;
} finally {
if (identical(_runFuture, future)) _runFuture = null;
}
}Also: Regression test at test:1139-1148 (W-TEST-ORDER-INSENSITIVE): pingCompleter.complete() fires before await teardownDone, so scope.release() is always called once regardless of drain order. Test passes whether or not drain actually waited. Fix: record events list and assert ['ping_completed', 'scope_released'] ordering.
⚠️ Warning Details
W-TEAR-CHAIN-NAV [🆕 this round, single-audit, 🟡Med] unified_diagnostics_view.dart:157 — .then() navigation skipped on teardownDone error
// lines 151-163
unawaited(
notifier.teardownDone
.whenComplete(() => _isBackNavigating = false) // ← guard released unconditionally ✓
.then((_) { // ← navigation ONLY on success
if (!mounted) return;
if (router.canPop()) { router.pop(); } else { router.goNamed(RouteNamed.uspMenu); }
}),
);If teardownDone completes with an error, .whenComplete() resets the guard (correct), but .then(onValue) is skipped → navigation never happens → user stuck on page. In practice, the teardown IIFE fully wraps ops in try/catch (near-zero error probability today), but the defensive contract is absent. Reviewer A rates as Suggestion; Reviewer B rates as Warning. Presented both; Austin to decide level. Fix: move navigate logic into .whenComplete() or add .catchError fallback.
W-TEST-ORDER-INSENSITIVE [carry-over, 🟢High] test/.../unified_diagnostics_notifier_test.dart:1139-1148 — drain-skip undetectable by current test
// test lines 1139-1148
pingCompleter.complete(_createPingResult('1.1.1.1')); // ← ping completed first
await preQualFuture;
await notifier.teardownDone;
verify(() => mockScope.release()).called(1); // ← verifies count, not orderTest resolves ping before awaiting teardown — scope.release() is always called once, regardless of whether drain ran. If drain was skipped (_runFuture == null), release() fired immediately (before ping) — but by verify() time both events have happened, test passes. B2 Verdict: Pre-existing (not introduced by R11 diff) → comment-only.
W-TEARDOWN-OVERWRITE [carry-over, 🟡Med] notifier.dart:265, 323, 369 — _teardownFuture unconditionally overwritten
Three entry points (cancel(), goBack() preQualifying, goBack() running) unconditionally assign _teardownFuture without checking if a prior teardown is still in progress. If a second cancel() fires while first teardown still parked on await inFlight, the reference is dropped, old teardown is orphaned, and teardownDone resolves against the new (not-yet-started-drain) future. Suggested fix (PeterJhong): self-identifying pattern using identical(). B2 Verdict: Pre-existing → comment-only.
W-RETURN-MENU-GUARD [carry-over, 🟡Med] unified_diagnostics_view.dart:168 — _returnToMenu has no double-tap guard
AppButton.onTap: () => _returnToMenu(context, ref) — no guard equivalent to _isBackNavigating. Rapid double-tap on "Return to Dashboard" from completed state can launch two concurrent async teardown+navigate chains; second cancel() may clobber _teardownFuture (W-TEARDOWN-OVERWRITE), and two concurrent context.pop() calls may double-pop navigation stack. B2 Verdict: Pre-existing → comment-only.
✅ What Looks Good
- W-GUARD-RESET fully resolved (🟢High, dual-audit):
_isBackNavigatingnow reset via.whenComplete(() => _isBackNavigating = false)unconditionally — guard cannot get permanently stuck even if teardown completes with error..whenCompletecorrectly precedes.then, ensuring reset fires on both success and error paths. - W-ASYNC-VCALLBACK fully resolved (🟢High):
_handleBackisvoid, correctly fits theVoidCallback?onBackTapslot — no more silentFuture<void>discard. _isBackNavigatingsingle-flight guard (🟢High): Declared at line 131, checked at entry (line 138), set at line 148, reset in.whenComplete(line 156). Full guard lifecycle correct.- Router pre-capture (🟢High):
GoRouter.of(context)captured beforenotifier.cancel()mutates state — navigation survives widget unmount. mountedcheck preserved (🟢High):if (!mounted) returnin.then()callback correctly guards navigation when widget disposed before teardown completes.goBack()handled-internally path (🟢High): Earlyreturnbefore_isBackNavigating = truewhengoBack()returns true — guard not unnecessarily locked for internally-handled back steps.dart:asyncimport added (🟢High): Required forunawaited().
Cross-reviewed by two independent agents (security+correctness / architecture+maintainability) + spec compliance agent. Automated — please sanity-check before merge.
| // completes with an error — so the app-bar back button can never get | ||
| // permanently stuck disabled. | ||
| .whenComplete(() => _isBackNavigating = false) | ||
| .then((_) { |
There was a problem hiding this comment.
W-TEAR-CHAIN-NAV [Warning, Med confidence] — .then(onValue) fires only on success. If teardownDone ever completes with an error, .whenComplete() correctly resets _isBackNavigating, but navigation is silently dropped and the user is stuck on the page. Current teardown IIFE wraps everything in try/catch (near-zero probability in practice), but defensive contract is absent.
Suggested fix — merge navigate into .whenComplete():
unawaited(
notifier.teardownDone.whenComplete(() {
_isBackNavigating = false;
if (!mounted) return;
if (router.canPop()) router.pop();
else router.goNamed(RouteNamed.uspMenu);
}),
);
PeterJhongLinksys
left a comment
There was a problem hiding this comment.
🤖 Automated Review — Round 7 · 2fce6f5..8f9daeb (incremental)
Verdict: 💬 Comment — The whenComplete guard-release is the correct fix for W-GUARD-RESET and lands cleanly, but the author's own W-TEAR-CHAIN-NAV was not applied in this commit, and Round 6's Critical (startWithPreQualifier never assigns _runFuture) is still open in head — which is exactly the future this round's guard hangs off.
| Conf. | Where | Issue (one-liner) | |
|---|---|---|---|
🔴 [XX] |
🟢 | providers/unified_diagnostics_notifier.dart:128-219, :323-338 |
Carried over from Round 6, still unfixed. startWithPreQualifier() assigns no _runFuture, so the preQualifying teardown drain is a permanent no-op and scope.release() runs while pingInternet is in flight — teardownDone resolves early, so this round's "navigate only after full teardown" guarantee does not hold on that path. |
[XX] |
🟢 | views/unified_diagnostics_view.dart:151-165 |
.whenComplete().then() has no onError: whenComplete forwards the original error, .then(onValue) does not absorb it, and unawaited() only silences the lint — so an error completion both drops the navigation and escapes as an uncaught async error. This is the author's own W-TEAR-CHAIN-NAV, not applied in 8f9daeb0. |
[XX] |
🟢 | views/unified_diagnostics_view.dart:153-155 |
Comment overclaims "can never get permanently stuck disabled". whenComplete fires only on completion, and the teardown await inFlight is bounded by the op timeout (ping 30 s / traceroute 120 s) — the Back button can still be silently dead for up to ~2 minutes, with no setState, no disabled style, no spinner, no .timeout(). W-3 is half-fixed. |
[XX] |
🟢 | views/unified_diagnostics_view.dart:131, :148-165 vs :168-182; views/widgets/diagnostic_results_view.dart:48, :70-86 |
[Divergent Change] This round widens the gap between the three exit paths: _handleBack (sync + flag + whenComplete/then + captured router + mounted), _returnToMenu (async, no guard, context.mounted), onDone (async, no guard, no liveness check). Same semantics, three orchestration models. W-4 unfixed. |
[X] |
🟢 | providers/unified_diagnostics_notifier.dart:108, :132, :227 |
W-1 unfixed: _teardownFuture = null is still unconditional with no completion check, so teardownDone can fall through to Future.value() while a teardown is still parked on await inFlight. |
[X] |
🟢 | providers/unified_diagnostics_notifier.dart:265-280, :323-338, :369-384 |
W-2 unfixed: the three teardown closures are still byte-identical apart from the log string (plus a 4th variant in ref.onDispose:71-81). The author's own _invalidateActiveRun() proposal is the reason call sites keep getting missed. |
[X] |
🟢 | providers/unified_diagnostics_notifier.dart:7, :91-96, :275, :333, :379 |
W-5 unfixed (Art. XIII): the notifier still imports network_diagnostics_executor.dart directly and does 4 raw catch (e). Correction to Round 6: DiagnosticsScopeService is not unused — speed_test_notifier.dart:64 and manual_tools_notifier.dart:46 both use it, so the real issue is two scope-acquisition models inside one feature. |
[X] |
🟢 | providers/unified_diagnostics_notifier.dart:96-99 |
W-6 unfixed: no _isCurrent(gen) after await acquireScope(). |
[X] |
🟢 | providers/unified_diagnostics_notifier.dart:167-170 |
W-7 unfixed: the guard still fires after the USP Operate is sent. |
[X] |
🟢 | views/widgets/diagnostic_results_view.dart:83-85 |
W-8 unfixed: unconditional router.goNamed() after two awaits, no liveness/one-shot guard. |
[X] |
🟡 | dashboard/mascot/mascot_providers.dart:172 |
W-9 unfixed: cancel() without awaiting teardownDone; nothing enforces the contract because teardownDone is a public getter. |
💡 [XX] |
🟢 | test/page/unified_diagnostics/ |
This fix ships with no test, and more importantly it is untestable as designed: _isBackNavigating is a private field that triggers no rebuild and has no observable effect (grep _isBackNavigating test/ → 0 hits). Promoting "teardown in progress" to a notifier state field would fix this, W-3's missing visible state, and W-4's duplication at once. |
💡 [X] |
🟢 | views/unified_diagnostics_view.dart:156-157 |
Guard release and navigation are one microtask apart. Not user-triggerable today (the event loop drains microtasks before the next input event), but making the whenComplete callback async later would open a double-navigation window. Merging into a single whenComplete block removes the hazard. |
💡 [X] |
🟢 | views/unified_diagnostics_view.dart:126-131 |
_isBackNavigating is declared between methods (after five _buildXxx()), while _scrollController sits at :29. Fields belong at the top of the class; the formatter will not catch this. |
💡 [X] |
🟢 | providers/unified_diagnostics_notifier.dart:280, :338, :384; views/unified_diagnostics_view.dart:133-137 |
Carried over and unfixed: unawaited(_teardownFuture!) force-unwraps a just-assigned field, and _handleBack's UnifiedDiagnosticsState state parameter is still never read (call site :58 passes it for nothing). |
Confidence: 🟢 High = read head code, evidence quoted · 🟡 Med = file:line + reasoning, not fully proven · ⚪ Low = speculative.
[X] = raised by 1 agent · [XX] = raised independently by both agents.
🔴 Critical — evidence chain
C-1 (carried over, Round 4→7) · startWithPreQualifier() never assigns _runFuture → the preQualifying teardown drain is a permanent no-op
1. Location. lib/page/unified_diagnostics/providers/unified_diagnostics_notifier.dart:128-219 (runner) and :323-338 (the goBack teardown that depends on it).
2. Code (head 8f9daeb0, verified this round). Every _runFuture occurrence in the file:
33: Future<void>? _runFuture;
112: _runFuture = future; // runFullDiagnostic
116: if (identical(_runFuture, future)) _runFuture = null;
242: _runFuture = future; // selectFlow
246: if (identical(_runFuture, future)) _runFuture = null;
266: final inFlight = _runFuture; // cancel() teardown
324: final inFlight = _runFuture; // goBack() preQualifying teardown
370: final inFlight = _runFuture; // goBack() running teardown
startWithPreQualifier() (:128-219) assigns nothing, yet it does acquire and use the scope:
167: try {
168: await _ensureScope();
169: final pingResult = await svc.pingInternet(repeatCount: 1);
170: if (!_isCurrent(gen)) return;So the goBack teardown reads null and skips straight to release:
323: _teardownFuture = () async {
324: final inFlight = _runFuture; // null during preQualifying
325: if (inFlight != null) { ... } // skipped
330: if (scope != null) {
332: await scope.release(); // released while pingInternet is in flight3. Downstream effect. network_diagnostics_executor.dart:391-397: release() → _awaiter.endSharedSession(), and _ensureLive() throws StateError afterwards. sse_operation_awaiter.dart:236-251: refcount 1→0 → 4 s linger → _runTeardown() removes the shared cleanups. The in-flight executeInSession stays parked on completer.future.timeout(30s) and can no longer receive OperationComplete → orphaned USP Operate that only ends on the 30 s timeout.
4. Trigger. Start screen → "Start" (diagnostic_start_view.dart:21 → startWithPreQualifier) → WAN Up with IP → runner parked at :169 with step == preQualifying → user taps app-bar Back → goBack() takes the :305 branch → _runFuture == null → drain skipped → immediate release(). Why this matters for this round specifically: _handleBack:151-165 now navigates off teardownDone, but on the preQualifying path teardownDone resolves almost immediately, so the "navigate only after full teardown" guarantee this PR is built around does not hold there — the quick-re-entry race the PR targets remains open.
5. Fix (mirrors the other two entry points, <8 lines).
Future<void> startWithPreQualifier() async {
final gen = ++_generation;
final future = _preQualifierFlow(gen); // extract current body
_runFuture = future;
try { await future; } finally { if (identical(_runFuture, future)) _runFuture = null; }
}Then make the Round 6 regression test order-sensitive: verifyNever(() => mockScope.release()) before pingCompleter.complete(...), verify(...).called(1) after — as written it passes either way, which is why "28/28 green" has not surfaced this across four rounds.
⚠️ Warnings — details
W-A · .whenComplete().then() has no onError (the author's own W-TEAR-CHAIN-NAV, not applied) — unified_diagnostics_view.dart:151-165
151: unawaited(
152: notifier.teardownDone
156: .whenComplete(() => _isBackNavigating = false)
157: .then((_) {
158: if (!mounted) return;
159: if (router.canPop()) { router.pop(); } else { router.goNamed(RouteNamed.uspMenu); }
164: }),
165: );whenComplete forwards the original error unchanged; .then(onValue) without onError passes it through; unawaited() suppresses the lint but installs no handler → the error reaches the Zone's uncaught-error handler (a test would fail on it). User-visible effect: Back does nothing on the first tap (navigation dropped), though the flag is released so a second tap works.
Triggerability, checked honestly: all three teardown IIFEs (notifier.dart:265-280, :323-338, :369-384) wrap the drain in catch (_) and release() in catch (e) + logger.w, so teardownDone cannot currently reject. This is therefore a defensive contract gap, not a user-triggerable bug — Warning, matching the author's own Med assessment. But W-2's three copies are precisely the mechanism by which one of them later loses its try/catch.
Suggested fix (absorbs the error and still navigates, unlike the version in the PR comment which navigates on failed teardown):
unawaited(
notifier.teardownDone
.then((_) {}, onError: (e, st) =>
logger.w('[Diagnostics] teardown failed during back-nav: $e'))
.whenComplete(() {
_isBackNavigating = false;
if (!mounted) return;
if (router.canPop()) { router.pop(); } else { router.goNamed(RouteNamed.uspMenu); }
}),
);W-B · Comment overclaims; W-3's other half is untouched — unified_diagnostics_view.dart:153-155
"the app-bar back button can never get permanently stuck disabled" is true only for the error-latch case. The flag is still held for the whole duration of await inFlight (ping 30 s, traceroute 120 s), during which :138 swallows every further Back tap with no setState, no spinner, no disabled style and no .timeout(). Suggest wording it as "never permanently" and adding .timeout(const Duration(seconds: 3), onTimeout: () {}) plus a visible loading state.
W-C · Three exit paths, three models (W-4, now wider) — view.dart:119, :148-165, :168-182; results_view.dart:48, :70-86
AppButton.onTap and UiKitPageView.onBackTap are both VoidCallback?, so onTap: () => _returnToMenu(context, ref) and onDone: () => _returnToDashboard(context, ref) still discard a Future<void> with no guard — the exact reasoning used to justify the _handleBack rewrite. Double-tapping "Done" still runs two cancel()s (the second overwrites _teardownFuture, see W-1) and two goNameds. The two paths in _UnifiedDiagnosticsViewState now differ on all four axes (guard / router capture / mounted vs context.mounted / await style). Suggest one _leaveDiagnostics({required String fallbackRoute}) behind a single flag.
W-D · Carried-over items, all verified unfixed in head 8f9daeb0
- W-1
notifier.dart:108,:132,:227— unconditional_teardownFuture = null;teardownDone(:47) still falls back toFuture.value(). - W-2
:265-280/:323-338/:369-384— three byte-identical closures plus the repeated++_generation; final scope = _scope; _scope = null;preamble; 4th variant atref.onDispose:71-81. - W-5
:7,:91-96, rawcatch (e)at:275,:333,:379. Correcting Round 6:DiagnosticsScopeServicehas two existing call sites (speed_test_notifier.dart:64,manual_tools_notifier.dart:46), so this is "two scope models in one feature", not "dead service". - W-6
:96-99— no generation check afterawait acquireScope(); a Back during acquisition installs an ownerless scope after teardown already reported done. - W-7
:167-170— guard after the Operate;_ensureLive()'sStateErroris swallowed by the outercatch (e). Compare the correct order at_runFullDiagnosticFlow:432-438. - W-8
results_view.dart:83-85— unconditionalgoNamedafter two awaits (defensive gap, 🟡: no interleaving proven inside this PR's scope). - W-9
mascot_providers.dart:172— barecancel(). MakingFuture<void> cancelAndAwaitTeardown()the only exit API and privatisingteardownDonewould make this a compile-time guarantee.
✅ What looks good
whenCompleteis the right tool for this latch — notcatchError(would swallow the error and change semantics), and not moving navigation intowhenComplete(would navigate even on a failed teardown). The PR comment separates "guard release" from "navigation" explicitly and correctly.- The reported W-GUARD-RESET symptom is genuinely gone: the "flag stuck
true, Back permanently dead" latch cannot happen any more. - No new race introduced by the
whenComplete-then-thenordering — both reviewers independently checked this: Flutter's event loop drains microtasks before the next input event, so a second Back tap cannot squeeze into the gap. Easy to misreport; it is not a bug today. mountedcheck kept inside.then()(:158) — no use-after-dispose was introduced by the restructuring.- Self-recovery reasoning holds: even if navigation is dropped,
cancel()(notifier:281) resets state to idle andgoBack()'s idle branch (:303-304) returnsfalse, so a second Back leaves the page. This is why W-A stays a Warning. - Excellent scope discipline: 1 file, +15/−9, guard-reset only — no drive-by refactors, no lint churn (Art. I §1.3).
- Layering and USP hard rules clean this round:
generated/imports appear only underunified_diagnostics/services/(0 inproviders/, 0 inviews/, Art. V §5.4); dependencies one-way View→Provider; no mutation (souspMutationLockProviderN/A), no new L1 provider, noPreservableContractredefinition (Art. IV);grep "on Exception catch" lib/page/unified_diagnostics lib/core/usp→ 0 hits, and the service consistently usescatch (e) { throw mapUspErrorToServiceError(e); }. - UI Kit first (Art. XV) respected —
AppLoader/AppText/AppGap/AppButton/UiKitPageViewthroughout, nothing hand-rolled. - Security surface clean: no hardcoded secrets/tokens in the 24 changed lines, no
flutter_secure_storagechanges, no new user-input handling, navigation targets areRouteNamedconstants only.
Note: no Dart/Flutter toolchain in the review sandbox, so the reported dart format / flutter analyze / 28-passing-tests results could not be independently reproduced (nothing in the diff contradicts them). prefer_const_constructors is set to ignore in this project, so no const-related findings were raised.
Cross-reviewed by independent agents (security+correctness / architecture+maintainability). Automated — please sanity-check before merge.
| // Release the single-flight guard unconditionally — even if teardown | ||
| // completes with an error — so the app-bar back button can never get | ||
| // permanently stuck disabled. | ||
| .whenComplete(() => _isBackNavigating = false) |
There was a problem hiding this comment.
W-TEAR-CHAIN-NAV is not applied in 8f9daeb0. whenComplete forwards the original error unchanged, .then(onValue) has no onError, and unawaited() only suppresses the lint — so an error completion of teardownDone both drops the navigation and escapes to the Zone uncaught-error handler. Not triggerable today (all three teardown IIFEs in unified_diagnostics_notifier.dart:265-280, :323-338, :369-384 wrap everything in try/catch), hence Warning, not Critical.
Note the version in your comment navigates even on a failed teardown. This absorbs the error and still navigates:
unawaited(
notifier.teardownDone
.then((_) {}, onError: (e, st) =>
logger.w(\"[Diagnostics] teardown failed during back-nav: $e\"))
.whenComplete(() {
_isBackNavigating = false;
if (!mounted) return;
if (router.canPop()) { router.pop(); } else { router.goNamed(RouteNamed.uspMenu); }
}),
);Also: the comment at :153-155 overclaims. whenComplete fires only on completion, and the teardown await inFlight is bounded by the op timeout (ping 30 s / traceroute 120 s) — the Back button can still be silently dead for up to ~2 min with no setState, spinner, or .timeout().
🟢 Adversarial re-review found this Critical [unified-diagnostics-notifier-dart-128-220] to be a FALSE POSITIVEAn independent read-only reviewer (different model) traced the finding and could not reproduce the claimed impact — the original Critical does not hold. It is withdrawn / downgraded. No fix is dispatched. Automated by gate.py confirm-lane; logged to calibrate the reviewer. |
🟢 Adversarial re-review found this Critical [spec-stop-immediately-unified-diagnostics-notifi] to be a FALSE POSITIVEAn independent read-only reviewer (different model) traced the finding and could not reproduce the claimed impact — the original Critical does not hold. It is withdrawn / downgraded. No fix is dispatched. Automated by gate.py confirm-lane; logged to calibrate the reviewer. |
PeterJhongLinksys
left a comment
There was a problem hiding this comment.
🤖 Automated Review — Round 8 · 8f9daeb..8f9daeb (re-evaluation, no code change)
Verdict: ✅ Approve — Re-evaluation (misjudgment cleared). The author disputed Round 7's Critical C-1. I re-read head 8f9daeb0 independently (the approval basis is my own re-reading, not the author's assertion) and traced the data flow to its endpoint: the mechanism is real, but the claimed user-visible impact is not reachable — the USP awaiter's linger window and _pendingTeardown latch absorb it. C-1 is therefore downgraded to a Warning (🟡 defensive gap), not withdrawn as a non-issue. With 0 Criticals remaining, this PR is approved.
| Conf. | Where | Issue (one-liner) | |
|---|---|---|---|
| 🟡 | providers/unified_diagnostics_notifier.dart:128-219, :323-338 |
Was 🔴 C-1, now downgraded. startWithPreQualifier() still assigns no _runFuture, so the preQualifying goBack teardown drain is a no-op and teardownDone resolves before pingInternet drains — a defensive gap in this PR's "navigate only after full teardown" invariant, but no reachable user-visible failure (see evidence). |
|
| 🟢 | views/unified_diagnostics_view.dart:151-165 |
Carried over (W-A): .whenComplete().then() has no onError; whenComplete forwards the original error and .then(onValue) does not absorb it, so a failed teardown drops the navigation and escapes as an uncaught async error. |
|
| 🟢 | views/unified_diagnostics_view.dart:131, :148-165 vs :168-182 |
Carried over (W-4, [Divergent Change]): _handleBack / _returnToMenu / onDone still implement the same "leave diagnostics" semantics three different ways (guard vs none, mounted vs context.mounted, captured router vs context). |
|
| 🟢 | providers/unified_diagnostics_notifier.dart:108, :132, :227 |
Carried over (W-1): _teardownFuture = null is unconditional with no completion check, so teardownDone can fall through to Future.value() while a teardown is parked on await inFlight. |
|
| 🟢 | providers/unified_diagnostics_notifier.dart:265-280, :323-338, :369-384 |
Carried over (W-2): three byte-identical teardown closures (plus a 4th variant at ref.onDispose), which is precisely why call sites keep getting missed. |
|
| 🟢 | providers/unified_diagnostics_notifier.dart:96-99, :167-170 |
Carried over (W-6/W-7): no generation re-check after await acquireScope(); the _ensureScope() guard at :167-170 runs before the Operate but its StateError would be swallowed by the outer catch (e) at :201. |
|
| 💡 | 🟢 | test/page/unified_diagnostics/ |
Carried over: the guard-reset fix ships with no test and is untestable as designed (_isBackNavigating is a private field with no observable effect). Promoting "teardown in progress" to a notifier state field would make W-1/W-3/W-4 testable at once. |
Confidence: 🟢 High = read head code, evidence quoted · 🟡 Med = file:line + reasoning, not fully proven · ⚪ Low = speculative.
No new commits since Round 7 — this round is a re-evaluation of the disputed Critical only, no new diff was reviewed.
🔎 Why C-1 is downgraded — full re-verification
What still holds (mechanism confirmed in head 8f9daeb0). startWithPreQualifier() (unified_diagnostics_notifier.dart:128-219) assigns no _runFuture, while it does acquire and use the scope:
167: try {
168: await _ensureScope();
169: final pingResult = await svc.pingInternet(repeatCount: 1);
170: if (!_isCurrent(gen)) return;and the preQualifying goBack teardown consequently skips its drain:
323: _teardownFuture = () async {
324: final inFlight = _runFuture; // null during preQualifying
325: if (inFlight != null) { ... } // skipped
330: if (scope != null) {
332: await scope.release();So teardownDone on that path does not represent "in-flight op drained". That part of Round 7 was accurate and is why this stays a Warning rather than being deleted.
What does not hold — the claimed impact is not reachable. Round 7 asserted an orphaned Operate plus a re-entry subscribe/unsubscribe race. Tracing to the endpoint:
network_diagnostics_executor.dart:391-395—release()calls_awaiter.endSharedSession().sse_operation_awaiter.dart:236-252— this does not tear down. Ref-count 1→0 only schedules a timer:
249: logger.d('[USP][SSE][Operate]: Ref-count 0 — lingering for '
251: _lingerTimer = Timer(_lingerDuration, _teardownLingeringSession);
138: static const _lingerDuration = Duration(seconds: 4);During the linger window _sharedCleanups are untouched, and the in-flight executeInSession matches via _manager.addWildcardHandler (:314), which is independent of _sharedRefCount. A single IPPing with NumberOfRepetitions=1 normally completes inside that window, so the common case has no orphan at all.
3. The re-entry race is separately closed at the awaiter level:
176: if (_lingerTimer != null && _sharedCleanups.isNotEmpty) { // reuse, no HTTP
189: final teardown = _pendingTeardown;
190: if (teardown != null) { await teardown; } // never overlaps a DELETESo "fresh subscribe POST races the pending unsubscribe DELETE" — the exact failure this PR targets — cannot occur through this path regardless of _runFuture.
4. Worst case that remains: ping outlives the 4 s linger → _runTeardown() (:270-292) removes the cleanups and the parked completer.future.timeout(30s) (:361) resolves as a TimeoutException. It is then discarded — the runner is already generation-invalidated (_isCurrent(gen) at :170) and the surrounding catch (e) at :201 logs and returns. No state clobber, no stuck UI, no leaked subscription.
Conclusion. The residual defect is a ≤30 s stray pending Operate plus a weakened invariant, with no path to user-visible incorrect behaviour. Per this reviewer's triggerability rule (a Critical must trace bad data to a proven consequence at its endpoint), that is a Warning, not a Critical. The recommended fix is unchanged and still cheap (~6 lines: extract the body into _preQualifierFlow(gen) and assign _runFuture exactly as runFullDiagnostic:111-117 and selectFlow:235-247 already do), and it would restore the invariant the rest of this PR is built on.
Note on calibration. Round 7 also over-stated one supporting claim: it described DiagnosticsScopeService as effectively bypassed dead weight. It is not — speed_test_notifier.dart:112 and manual_tools_notifier.dart:59 both use it (diagnostics_scope_service.dart:46-62). The accurate framing is "two scope-acquisition models coexist in one feature", already carried as W-5.
✅ What looks good
- The Round 7 fix itself is correct and minimal:
whenCompletereleases the single-flight guard even on a failed teardown, so the app-bar Back cannot latch permanently disabled. - Generation-based invalidation (
_generation/_isCurrent/_publish,:58-67) is the right replacement for the old shared_cancelledbool and correctly prevents stale runners from clobbering newer state. - Scope capture-then-null (
:263-264,:321-322,:362-363) is done consistently, so a new run's_ensureScope()cannot reuse a scope being released underneath it. - Layering and USP hard rules clean:
generated/imports appear only underservices/(Art. V §5.4); noon Exception catchin the touched code — the service usescatch (e) { throw mapUspErrorToServiceError(e); }(unified_diagnostics_service.dart:151-155); no new L1 provider, noPreservableContractredefinition, no mutation requiringuspMutationLockProvider(Art. IV). - No security surface change: no hardcoded secrets/tokens, no
flutter_secure_storagechange, navigation targets areRouteNamedconstants only. - Tight scope discipline throughout this PR — no drive-by refactors or lint churn (Art. I §1.3).
Cross-reviewed by independent agents (security+correctness / architecture+maintainability). Automated — please sanity-check before merge.
🟢 Adversarial re-review found this Critical [providers-unified-diagnostics-notifier-dart-128-] to be a FALSE POSITIVEAn independent read-only reviewer (different model) traced the finding and could not reproduce the claimed impact — the original Critical does not hold. It is withdrawn / downgraded. No fix is dispatched. Automated by gate.py confirm-lane; logged to calibrate the reviewer. |
AustinChangLinksys
left a comment
There was a problem hiding this comment.
🤖 Automated Review — Re-evaluation (misjudgment cleared)
Verdict: 💬 COMMENT (self-approve blocked by GitHub API) — Prior Critical H-1 re-evaluated against source code and confirmed false positive; withdrawn. 0 Critical remaining. GitHub's self-review restriction prevents formal APPROVE — verdict is COMMENT by API constraint, not by finding.
Re-evaluation Summary
| Finding | Disposition |
|---|---|
H-1 startWithPreQualifier — _runFuture never assigned; drain skips; scope released while pingInternet in-flight |
✅ Withdrawn — confirmed false positive (evidence below) |
All other Warnings and Suggestions from Round 10 remain on record — pre-existing/comment-only items, not merge blockers.
🔴 H-1 Critical Withdrawal — Evidence Chain
H-1 startWithPreQualifier() — Re-evaluated as FALSE POSITIVE
Original claim: _runFuture never assigned → drain skips → scope.release() fires while pingInternet in-flight → scope released under live op; fast re-entry races DELETE vs new subscribe POST.
_runFuture gap is real (unified_diagnostics_notifier.dart:128–219, confirmed). However, claimed consequence chain does not hold:
Evidence 1 — scope.release() starts a LINGER TIMER, not immediate SSE teardown:
// network_diagnostics_executor.dart:391–395
Future<void> release() async {
if (_released) return;
_released = true;
await _awaiter.endSharedSession(); // decrements ref count → starts linger timer
}Evidence 2 — endSharedSession() defers teardown via linger timer:
// sse_operation_awaiter.dart:236–252
Future<void> endSharedSession() async {
_sharedRefCount--;
if (_sharedRefCount > 0) { return; }
if (_lingerTimer != null) return;
_lingerTimer = Timer(_lingerDuration, _teardownLingeringSession); // deferred, not immediate
}Evidence 3 — In-flight ping captured scope ref before cancel; linger preserves session:
pingInternet → ping() → _requireScope().ping() → executeInSession() — already executing inside the awaiter. SSE subscription stays alive during linger window. Ping completes (or times out) normally.
Evidence 4 — _isCurrent(gen) discards result silently (notifier.dart:170):
if (!_isCurrent(gen)) return;No user-observable broken state even if ping completes post-cancel.
Evidence 5 — Fast re-entry reuses linger session, no DELETE/POST race:
// sse_operation_awaiter.dart:174–183
if (_lingerTimer != null && _sharedCleanups.isNotEmpty) {
_lingerTimer!.cancel(); // cancel timer
_lingerTimer = null;
_sharedRefCount = 1;
return; // reuse existing session — no new subscribe POST issued, no collision
}Conclusion: Drain is skipped (confirmed structural gap), but the linger timer preserves the SSE session for the in-flight op. Fast re-entry reuses the lingering session — no DELETE/POST collision path. The _runFuture gap is a defensive design concern (absence of explicit drain guarantee for startWithPreQualifier), not a confirmed Critical crash/corruption path. Consistent with gate.py confirm-lane adversarial review (Opus model) returning FALSE POSITIVE.
Re-evaluation by main agent independently reading source — not based solely on author/bot assertion. Gate.py confirm-lane (Opus adversarial) independently reached the same conclusion. Automated — please sanity-check before merge.
請暫停這個 review 迴圈人類 reviewer,接續我先前的總結。以下三點請求,不含任何新的技術 finding —— 我刻意不再開新戰場。 現況距離上一則 comment 約 9 小時:
Round 7–10 幾乎全花在 四輪、四個 commit,全部在修那個為了修 H-4 而生的 API;影響範圍也從 notifier 擴散進 這個迴圈的終止條件是「0 Criticals」,而 severity 是可以協商的 —— 所以它沒有自然的停點。這則 comment 就是停點。 1. H-1 請在 merge 前修掉(約 6 行)approve 那則的表格自己列在第一位:
我在 head 我不打算爭這個降級。 那份 re-verification 追得紮實, 這個 PR 的整體設計建立在一個 invariant 上 —— 「每個 run entry point 都要 claim 一個 generation 並註冊自己的 future」。 三個入口中兩個遵守,一個沒有。今天的「吸收」是否成立,取決於另一個檔案裡的
H-3 同理( 2. Round 10 請作為最後一輪H-1 與 H-3 修完後,請停止在這個 branch 上派送自動 review-fix task。 最後三則 comment 是連續三個 FALSE POSITIVE 判定,區域全部落在 3. 剩下的 warning 請開成 follow-up issue,不要再進這個 PRapprove 中 carried over 的六個 warning 應該另案處理。其中兩個的實際重要性高於它們的 🟡 標記,而且都是 approve 自己的原話:
這就是根因 —— 也正是 Round 1 以
結構上無法測試的 code,不該以 review 迴圈副產物的形式落在 release branch 上。 那則註記建議把「teardown in progress」提升為可觀察的 state field,方向是對的 —— 但那應該是它自己的一次改動,走它自己的 review。 建議(與先前一致)
我的偏好不變:把這個 PR 縮回 step-boundary guards,併發重構另開 issue、給它自己的 review 預算,不要放在 release branch 上。如果決定整包出,那是 @AustinChangLinksys 明確做出的決定 —— 但無論走哪條路,H-1 與 H-3 都應該在裡面。 該肯定的部分把話講清楚,這裡有幾件事做得好:
問題不在 reviewer 品質。問題是:一個退出條件可以協商的自我派工迴圈會一直跑下去,而先前沒有人畫線。 這則 comment 把線畫在這裡。 |
…uire (#1175 H-1/H-3) Close the two invariant gaps carried over in the PR approval — both are sites where the run-identity mechanism this PR is built on was not applied uniformly across all three run entry points / all post-await mutations. H-1: startWithPreQualifier() acquired and used the shared scope but, unlike runFullDiagnostic() and selectFlow(), assigned no _runFuture. cancel()/goBack() teardown drains _runFuture before releasing the scope, so a null _runFuture let the scope be released while pingInternet was still in flight (the unsubscribe DELETE vs next subscribe POST overlap this PR exists to prevent). Extract the body into _preQualifierFlow(gen) and register the future exactly as the other two entry points do — restoring the "every entry point claims a generation and registers its future" invariant. H-3: _ensureScope() was the only post-await mutation without a generation recheck — it unconditionally wrote `_scope = scope` after `await acquireScope()`. A stale runner revived inside acquireScope would overwrite the live run's scope, orphaning it (shared SSE ref-count never returns to 0). Thread gen through and, if the run is no longer current on resume, release the just-acquired scope instead of clobbering _scope. This is the acquire-side mirror of the release- side sink fixed in 37d2889. Regression tests (both proven load-bearing — fail when the fix is reverted): - cancel during preQualifier ping drains it before scope release (#1175 H-1) - revived run releases its own scope instead of orphaning the live run's (#1175 H-3) flutter analyze: no issues in changed files. dart format: clean. unified_diagnostics_notifier_test.dart: 30/30 pass.
AustinChangLinksys
left a comment
There was a problem hiding this comment.
🤖 Automated Review — Round 12 · 8f9daeb0..e8f566ac (incremental)
Verdict: ✅ APPROVE (posted as Comment — GitHub blocks self-approve) — H-1 & H-3 both fixed in this commit; 0 Critical; 3 Warnings (defensive gaps / design fragility, not crash-path).
Note on Hank's review (@HankYuLinksys, 00:33 UTC): H-1 (
startWithPreQualifiermissing_runFuture) and H-3 (_ensureScopepost-await mutation unguarded) are both addressed in this commit. Hank's request to open follow-up issues for remaining Warnings and stop the auto-review loop is noted — surfaced to Austin via Telegram.
Standards
| Conf. | Where | Issue (one-liner) | |
|---|---|---|---|
| ✅ | 🟢High | notifier.dart:146–166 |
H-1 FIXED: startWithPreQualifier now extracts _preQualifierFlow(gen) and assigns _runFuture, matching runFullDiagnostic and selectFlow patterns |
| ✅ | 🟢High | notifier.dart:94–110 |
H-3 FIXED: _ensureScope(int gen) now guards post-await _scope mutation with !_isCurrent(gen), releases newly-acquired scope on stale run |
| 🟢High | notifier.dart:~193 |
Missing if (!_isCurrent(gen)) return; between await _ensureScope(gen) and await svc.pingInternet() in _preQualifierFlow — all 3 sibling flows have this guard; _preQualifierFlow is the sole exception |
|
| 🟢High | notifier.dart:~107 |
[Defensive Gap] _ensureScope(gen) stale path does return scope after scope.release() — returns a released DiagnosticScope; safe now (callers discard return) but silent contract violation |
|
| 🟢High | notifier_test.dart |
Missing regression test for cancel-during-acquireScope in _preQualifierFlow; analogous test exists for intermittent flow (~line 964); gap means W-1 has no load-bearing test |
|
| 💡 | 🟡Med | notifier.dart:~331 |
goBack() running-case teardown does not assign _teardownFuture; teardownDone returns stale future after back-navigation (pre-existing; candidate follow-up issue) |
| 💡 | 🟢High | notifier_test.dart:~1019 |
Second new test (back-from-preQualifying) validates a pre-existing #1148 fix; comment attribution says "PR #1175 review" — minor clarification suggested |
| 💡 | 🟡Med | notifier.dart:392,550,810 |
[Duplicated Code] try { await _ensureScope(gen); } catch + gen-guard idiom repeated 3×; encapsulating gen into _ensureScope would collapse these |
Confidence: 🟢High = code-verified · 🟡Med = located + reasoned, not fully confirmed · ⚪Low = speculative, please double-check.
[X] = 1 agent · [XX] = 2 agents flagged independently → higher confidence (Critical only).
Spec (#1148 — "Cancel Diagnostics button not action immediately")
| Conf. | Where | Issue (one-liner) | |
|---|---|---|---|
| ✅ | 🟢High | All 3 entry points | Spec fully satisfied: all 3 run entry points now register _runFuture; cancel() drains before scope release on all paths; teardownDone prevents DELETE/POST overlap on quick re-entry |
⚠️ Warning Details
W-1 — Missing gen guard in _preQualifierFlow between _ensureScope and pingInternet
Location: unified_diagnostics_notifier.dart, _preQualifierFlow, Step 2 try-block (diff line ~193 in new file)
Evidence — diff (new code):
// _preQualifierFlow — Step 2:
try {
await _ensureScope(gen); // stale path: releases new scope, returns released scope
final pingResult = await svc.pingInternet(repeatCount: 1); // ← NO gen guard here
if (!_isCurrent(gen)) return; // guard arrives only AFTER ping completesComparison — all sibling flows (verified by reading source):
_runFullDiagnosticFlow~:399:await _ensureScope()→ immediateif (!_isCurrent(gen)) return;→ ping_runInternetDiagnostics~:556: same pattern_runIntermittentDiagnostics~:816: same pattern (fixed in this PR series)
Consequence: When cancel() fires while acquireScope is in-flight: gen becomes N+1; _ensureScope(N) releases new scope and returns WITHOUT calling _svc?.attachScope(scope). pingInternet then executes without a fresh scope binding. cancel() drains _runFuture before releasing the held _scope, so the scope stays live during the ping — the exact race is lower severity than H-1 was, but it's a missed guard against the pattern this PR aims to enforce.
Fix (one line):
try {
await _ensureScope(gen);
if (!_isCurrent(gen)) return; // ← matches all sibling patterns
final pingResult = await svc.pingInternet(repeatCount: 1);W-2 — _ensureScope(gen) stale path returns a released scope (fragile contract)
Location: unified_diagnostics_notifier.dart, new _ensureScope(int gen) stale block
Evidence:
if (!_isCurrent(gen)) {
try {
await scope.release();
} catch (e) {
logger.w('[Diagnostics] Failed to release scope for stale run: $e');
}
return scope; // ← returns the scope that was just released
}Method signature Future<DiagnosticScope> implies a usable scope. Currently safe (all callers await _ensureScope(gen) without assigning return value). Directly enables W-1 because the stale path also skips _svc?.attachScope(scope).
Fix options: (1) Throw _StaleDiagnosticRunError from stale path — caught by existing catch blocks. (2) Future<DiagnosticScope?> returning null on stale path with null-check at callers. (3) Minimum: add if (!_isCurrent(gen)) return; after every await _ensureScope(gen) call (fixes W-1, leaves contract fragility).
W-3 — Missing test: cancel-during-acquireScope in _preQualifierFlow
Location: unified_diagnostics_notifier_test.dart
Only one startWithPreQualifier test exists — it hangs checkWanStatus, not acquireScope. No test covers: hang inside acquireScope → cancel() → stale _ensureScope(gen) releases scope → verify pingInternet NOT called.
The analogous test for the intermittent flow (hang acquireScope → cancel → revive stale runner → confirm scopeA released, scopeB intact) exists at line ~964. Same pattern needed for the preQualifier flow to close the coverage gap and catch W-1 regressions.
✅ What looks good
- H-1 fully fixed:
startWithPreQualifiernow uses identical_runFuturelifecycle asrunFullDiagnosticandselectFlow. The three entry points are now structurally symmetric. - H-3 fully fixed:
_ensureScope(int gen)adds generation guard on post-await_scopeandattachScopemutations. Acquire-side mirror of the release-side guard from37d28891. - All 4
_ensureScopecall sites correctly updated to passgen. - 130 new regression test lines with load-bearing tests for both H-1 (drain-before-release) and H-3 (stale-acquire orphan prevention).
- Generation invariant restored: all 3 run entry points now claim gen → register
_runFuture→ propagate gen to entire flow. - Spec #1148 fully satisfied: Cancel-ignored-mid-flow bug is closed; all entry points are drainable.
Cross-reviewed by two independent agents (security+correctness / architecture+maintainability). Automated — please sanity-check before merge.
H-1 / H-3 verified — no further rounds requested from meRead VerifiedH-1 — the body is extracted into H-3 — Tests 26 → 30, both new ones reported as load-bearing (fail when the fix is reverted). On the loop
Acknowledged, and thank you for surfacing the follow-up-issue request rather than dispatching another fix task. That is the outcome I was asking for. W-1 and W-2 — agree they are follow-up material, with one noteRound 12's own W-1 is real and I confirmed it at head: // :196
await _ensureScope(gen);
final pingResult = await svc.pingInternet(repeatCount: 1); // no guard between
if (!_isCurrent(gen)) return; // guard lands after the pingAll three siblings guard immediately after the acquire ( One correction to the write-up, so it does not carry forward at inflated severity: W-1 states So: real, worth fixing, not merge-blocking. Consistency is the actual argument for W-1 — four flows should not have three shapes; that asymmetry is precisely the pattern that produced ten rounds of "found another site where the guard was omitted." If you want to fold W-1's single line in before merge I have no objection — it adds no API and no new surface area, which is what distinguishes it from the Round 7–10 sequence. But I am not asking for a round 13, and W-1 / W-2 / the Status from my sideH-1 and H-3 were my only merge blockers and both are closed. No further review rounds requested. Remaining items are follow-up issues, and the scope question — this now stands at 13 commits and ~813 insertions for an S3 — is a release-management call for @AustinChangLinksys, not a code-quality objection. The diff as it stands is shippable. Good work closing these two out cleanly, and credit to the reviewer lanes for finding W-1 in their own fix rather than declaring done. |
Summary
Fixes #1148 — pressing Cancel Diagnostics (or Back) mid-run had no effect until the entire diagnostic flow finished.
Root cause (#1148)
_runFullDiagnosticFlow/_runInternetDiagnosticschecked cancellation only once at the top, then ran ~10 sequential awaited steps with no interleaved guards.cancel()set a flag butawaited the in-flight run future, so once past the entry check the flow ran every remaining step to completion before the cancel could take effect. Confirmed from the attached UI log: cancel fired right after DNS lookup, yet the pipeline kept running the speed test and every remaining check. All USP ops returnedComplete— a UI/state gap, not firmware.What this PR does now (head)
The fix evolved through review into a run-identity model rather than a shared bool:
_generation/_isCurrent(gen)/_publish(gen, …)— every run entry point (runFullDiagnostic,startWithPreQualifier,selectFlow) claims a fresh generation; every step guard checks_isCurrent(gen), every post-awaitstate write goes through_publish(gen, …). A revived stale runner can neither advance the step machine nor clobber a newer run's state.cancel()/goBack()— state resets synchronously (UI leaves the running view on the next frame); the in-flight drain + shared-scope release run off the critical path.teardownDone— exposes the off-critical-path teardown future so navigation paths (_returnToDashboard/_returnToMenu) can wait for the shared USP scope's unsubscribe DELETE to complete before the next acquire's subscribe POST, avoiding a firmware subscription drop on quick re-entry._ensureScope(gen)— scope acquisition is generation-guarded on both the release side (cancel/back capture-then-null) and the acquire side (a stale runner releases the scope it just acquired instead of orphaning the live run's).Changed files
lib/page/unified_diagnostics/providers/unified_diagnostics_notifier.dart— generation model, scope lifecycle,teardownDonelib/page/unified_diagnostics/views/unified_diagnostics_view.dart— single-flight back-nav guard,teardownDoneawaitlib/page/unified_diagnostics/views/widgets/diagnostic_results_view.dart— captured router +teardownDoneawaittest/page/unified_diagnostics/providers/unified_diagnostics_notifier_test.dart— regression tests (30 total; every fix(diagnostics): honor Cancel mid-flow by guarding step boundaries (#1148) #1175 fix has a load-bearing test)Why this grew (commit map)
6140045589955b47cancel()blocking on the speed-test awaite0384884startWithPreQualifiermissing flag reset37d28891_generation0f75a2bec76634bbgoBackfrom preQualifying missing++_generation82453ed6…8f9daeb0teardownDonelifecycle + view-layer navigation (dashboard/menu/back)e8f566ac_runFuturein preQualifier + generation-guard the scope acquireVerification
flutter analyze(changed files): No issues found (3 pre-existingcurly_bracesinfos remain in untouchedunified_diagnostics_service.dart/diagnostic_running_view.dart).dart format --set-exit-if-changed: clean.flutter test unified_diagnostics_notifier_test.dart: 30/30 pass, including load-bearing regression tests for Cancel Diagnostics button not action immediately #1148 and every fix(diagnostics): honor Cancel mid-flow by guarding step boundaries (#1148) #1175 race (cancel→restart clobber, intermittent post-scope, preQualifying back clobber + scope release, H-1 drain-before-release, H-3 stale-acquire orphan).Follow-ups (tracked separately, not in this PR)
Carried-over 🟡 warnings from the approval — deferred so they get their own review budget rather than extending this loop:
_handleBack/_returnToMenu/onDone).Refs #1148