Clear deferred-medium backlog: single-flight status write + example mounted guards - #34
Conversation
checkAndInstall's contract is that every false return leaves its reason in CodePush.status. The single-flight early return (a second check while one is already running) was the one false return that wrote nothing, so a losing caller surfacing status after false rendered the in-flight check's foreign progress state as its own result. It now writes 'A check is already running' before returning. The write is transient by design: the in-flight check keeps overwriting status as it progresses, and ValueNotifier notifications are synchronous, so the 'Patch active' edge is always delivered before any overwrite. Adds a regression test in the single-flight group. Fixes #32
The overlay re-keys the app subtree when a patch activates, which disposes the demo State mid-await; a setState landing after that throws in debug builds. Every setState that follows an await in _loadStatus, _manualCheck (including the async onUpdateReady callback and the catch branch), and _rollback (both catch branches) now bails out first when the State is no longer mounted. Pre-await synchronous setState calls need no guard and are unchanged; the early returns skip only UI updates on a disposed State, no side effects are lost. Fixes #33
🔴 CriticalNone. No crash path, no new network/file-I/O surface, and no source- or behaviour-breaking change to the public API ( 🟠 MediumThe new loser write makes the "every Before this PR, nothing outside the in-flight check could write
Those awaits are not short: Concretely: an iOS device is offered a patch whose container header doesn't match. The surgical fix is to move the Worth naming the root cause too: "every 🟡 Low
🟢 Positives
Static review — |
… test Review round 1: the four sites where a check writes a terminal error status and then awaits a best-effort call (telemetry POST / rollback) before returning false now write the status after the await, so a concurrent check's 'A check is already running' write during that window cannot replace the actionable message. Audited all other status writes: no other site has the write-await-return-false shape. Also: the single-flight regression test no longer depends on wall-clock timing (the guard and the loser's status write both land before the first await, so both calls run in one synchronous turn); the example's onUpdateReady comment now states the real reason for the mounted guard (the callback is synchronous, but the overlay can re-key the subtree mid-check); CHANGELOG's Unreleased section now notes the behavior change instead of claiming doc-only changes.
|
Round 1 addressed in 9f546e2: Medium (fixed). All four cited sites (engine-absent baseline guard, engine ABI mismatch, and the two Lows (all fixed).
Result-object suggestion: acknowledged, deliberately not taken here.
|
|
Round 2 — re-reviewed against 🔴 CriticalNone. No native/platform-channel change, no new network or file-I/O surface, no source-breaking change to the public API. The reordered 🟠 MediumThe OTA kill-switch branch is the same write- The Round 1 response says every other 520 if (data['ota_disabled'] == true) {
521 status.value = 'OTA disabled by server';
522 try {
530 await _rollbackInternal(quarantine: false); // <-- async gap
531 status.value =
532 'OTA disabled by server — patch removed (restart to apply)';
533 } catch (_) {
534 // Best-effort: a clean device has nothing to revert.
535 }
536 return false;
537 }On the success path The throw path is the common, healthy case, not an exotic one. Failure scenario: an unpatched device, server flips the OTA kill switch. The periodic-timer Surgical fix, same shape as the four already applied — write the terminal status after the if (data['ota_disabled'] == true) {
try {
await _rollbackInternal(quarantine: false);
status.value =
'OTA disabled by server — patch removed (restart to apply)';
} catch (_) {
// Best-effort: a clean device has nothing to revert.
status.value = 'OTA disabled by server';
}
return false;
}
This also makes the CHANGELOG line accurate: as it stands, "a concurrent 🟡 Low
🟢 Positives
Static review — no Flutter toolchain in this environment, so the 153/153 and |
…havior Review round 2: - CHANGELOG: the entry claimed a concurrent checkAndInstall no longer overwrites the active check's status message, but the loser's 'A check is already running' stamp does transiently replace the winner's in-progress status until its next write. Reworded to state what is actually guaranteed: the overlapping call reports its own reason instead of surfacing the other check's state. - The two status-reorder comments on the payload-load error paths claimed the terminal message 'must be what stands when we return'. That guarantee does not hold there: the install path returns the load future without awaiting it (releasing the single-flight guard before the rollback completes) and the cold-boot/reload callers never take the guard, so a concurrent full check can still overwrite the message later. The reorder stays (it is still the last write this method makes); the comments now describe it as best-effort ordering, not a guarantee.
|
Round 3 — re-reviewed against the current merge head. The CHANGELOG rewording and the two "best-effort ordering, not a guarantee" comment corrections from the last round landed. The Round 2 Medium did not, and I confirmed it is still present at HEAD; re-raising with the verification below rather than restating it. 🔴 CriticalNone. No native or platform-channel change, so Dart↔Android/iOS parity is untouched. No new network or file-I/O surface, no change to download verification or URL scheme gating. No source-breaking change: 🟠 MediumThe OTA kill-switch This was raised last round and has not been addressed or refuted — no reply, and the code is unchanged at HEAD: 520 if (data['ota_disabled'] == true) {
521 status.value = 'OTA disabled by server';
522 try {
530 await _rollbackInternal(quarantine: false); // <-- async gap
531 status.value =
532 'OTA disabled by server — patch removed (restart to apply)';
533 } catch (_) {
534 // Best-effort: a clean device has nothing to revert.
535 }
536 return false;
537 }The success path rewrites at Why this matters more than an exotic edge: the catch path is the majority of the fleet, not a corner case. Failure scenario: unpatched device, operator flips the kill switch. The periodic-timer Same three-line shape as the four already applied: if (data['ota_disabled'] == true) {
try {
await _rollbackInternal(quarantine: false);
status.value =
'OTA disabled by server — patch removed (restart to apply)';
} catch (_) {
// Best-effort: a clean device has nothing to revert.
status.value = 'OTA disabled by server';
}
return false;
}
One open Medium is mergeable under the deferred-medium rule, but filing a 🟡 Low
🟢 Positives
Static review — no Flutter toolchain in this environment, so the 153/153 and |
…catch path too The success path already rewrote status after the awaited revert, but the catch path — the majority-fleet path, since unpatched devices have nothing to roll back — kept only the pre-await write, which a concurrent check's single-flight status could overwrite during the revert's async gap. Move the write into both post-await branches and pin the ordering with a deterministic concurrent-check test.
|
Round-3 Medium fixed in 040614b: the OTA kill-switch branch no longer writes its status before the awaited revert attempt — both post-await branches now write it (success keeps 'OTA disabled by server — patch removed (restart to apply)', the catch path writes 'OTA disabled by server'), so a concurrent check's 'A check is already running' can no longer stand as the final status on unpatched devices. Added a deterministic regression test that fires a concurrent check from inside the revert's async gap (fails on the previous code, passes now). Re-audited every remaining |
|
Round 4 — re-reviewed against the current merge head ( 🔴 CriticalNone.
🟠 MediumNone. Round-3 finding verified fixed — I re-ran the audit for the same write-
🟡 Low
🟢 Positives
Static review — no Flutter toolchain in this environment, so the 154/154 and |
…tus contract docs - New hardening test pins the reordered-write class at its cheapest site: a concurrent check() fired while the winner is parked on the awaited incompatible-baseline telemetry POST cannot overwrite the terminal 'Incompatible baseline: engine ABI mismatch' status (verified red on the pre-fix ordering). - The kill-switch concurrency test now pins that the loser actually stamped 'A check is already running' inside the revert window. The status is captured in the mock handler and asserted in the test body: an expect() thrown inside the handler is swallowed by the kill-switch branch's own catch around the rollback await (verified empirically), so the inline form would itself be vacuous. - Docs now state the enforced contract: checkAndInstall's dartdoc says every false return leaves its reason as that check's final status write and a losing concurrent call stamps its own status without disturbing the active check's terminal message; README lists 'A check is already running' among the status values; both the status dartdoc and README reconcile the transition-not-level wording with the ordering guarantee (which transition a check ends on, not a level).
|
All three Round-4 Lows addressed in
Verification: |
|
Round 5 — re-reviewed against the current merge head ( 🔴 CriticalNone.
🟠 MediumThe new public contract is stated unconditionally, but the PR's own comments document it as best-effort on the iOS install path — The new dartdoc says, without qualification:
Both halves are false on the iOS post-download load path, and the same commit says so eleven hundred lines further down: 1951 // Written after the awaited rollback (file deletes + telemetry
1952 // POST) so this upgrade hint is the last write this method
1953 // makes. The single-flight guard is not held here (the install
1954 // path returns this future without awaiting it, ...), so a
1955 // concurrent full check can still overwrite it later —
1956 // best-effort ordering, not a guarantee.That comment (and its twin at Failure scenario. iOS, first patch of the session, In-scope fix is one clause on each doc block — e.g. "…except the iOS post-download load, where the guard is released before the load completes (best-effort ordering there)." That makes the public docs agree with Separately, and not a request for this PR: 🟡 Low
🟢 Positives
Static review — no Flutter toolchain in this environment, so the 155/155 and |
The round-5 review is right that the new dartdoc and README stated the final-write guarantee unconditionally while the code's own comments (and the guard-release at the un-awaited install return) document the iOS post-download load path as best-effort ordering. Add the qualifying clause to both doc blocks so the public docs agree with the code.
|
Round-5 Medium addressed in 6c8f800: both doc blocks now carry the iOS post-download qualifier (guard released before the load completes → best-effort ordering there), matching the code comments at the two load-path sites. The separate |
|
Round 6 — re-reviewed against the current merge head ( 🔴 CriticalNone.
🟠 MediumNone. Round-5 Medium verified fixed — The 🟡 Low
🟢 Positives
Static review — no Flutter toolchain in this environment, so the 155/155 and |
|
Round-6 clean (no Critical, no Medium; the three Lows are documentation/scope items the review itself marks as non-gating — tabled on record here). Merging under the standard gate. |
Clears the two open
deferred-mediumissues on this repo. Each was a Medium review finding deferred at merge time under the deferred-medium rule and is now being fixed.Fixes #32 — checkAndInstall's single-flight early return now writes its reason to status
checkAndInstall's documented contract is that everyfalsereturn leaves its reason inCodePush.status. The single-flight early return (a second check arriving while one is in flight) was the onefalsereturn that wrote nothing, so a losing caller that surfacedstatusafterfalserendered the in-flight check's progress state as if it were its own result. It now writes'A check is already running'before returning, which also makes the example's "writes status before every false return" comment accurate (all 18false-return sites verified).The write is safe against the 'Patch active' transition: notifications are synchronous, so that edge is always delivered before any overwrite, and the in-flight check keeps overwriting status as it progresses. The loser's stamp can transiently replace the winner's in-progress message until the winner's next write — that is the accepted tradeoff of the contract fix; the four terminal error statuses that were followed by a best-effort await are now written after that await so the actionable message is the last write those paths make. A regression test is added in the single-flight group.
Fixes #33 — example guards every setState-after-await with a mounted check
The overlay re-keys the app subtree when a patch activates, which disposes the demo page's State mid-await; a
setStatelanding after that throws in debug builds. EverysetStatethat follows anawaitin_loadStatus,_manualCheck(including the asynconUpdateReadycallback and the catch branch), and_rollback(both catch branches) now returns early when the State is unmounted. Pre-await synchronoussetStatecalls need no guard and are unchanged; the early returns skip only UI updates on a disposed State — no side effects are lost.Review rounds
Round 2 corrected the CHANGELOG entry to state only what is guaranteed (the overlapping call reports its own reason, rather than 'never overwrites'), and reworded two code comments that overstated the terminal-status ordering as a guarantee on paths where the single-flight guard is not held.
Checks
flutter analyze: no new issues (6 pre-existingavoid_printinfos inlib/src/code_push.dart; example package clean)flutter test: 153/153 pass, including the new regression test (now deterministic — no wall-clock dependency)