Skip to content

Overlay hardening: supersede-race fix, widget coverage, patch-active constant - #36

Merged
fonkamloic merged 14 commits into
mainfrom
fix/overlay-hardening
Sep 3, 2026
Merged

Overlay hardening: supersede-race fix, widget coverage, patch-active constant#36
fonkamloic merged 14 commits into
mainfrom
fix/overlay-hardening

Conversation

@fonkamloic

Copy link
Copy Markdown
Contributor

Fixes #30, fixes #29, fixes #31.

Authorship note: #30's update-flow change was implemented under the reviewer lane per the workspace's work/review split; #29/#31 by the implementation lane.

…am (#31, #29)

#31 — `'Patch active'` was app-facing API written as a bare literal in
three places and compared in a fourth, so an edit to the text could
silently break every caller latching on it. Promote it to
`CodePush.statusPatchActive` and route all four sites plus the docs
through the constant. The string itself is unchanged.

#29 — `CodePushOverlay.initState` drives the live update cycle (server
check, patch-directory file I/O) and the update-ready signal is private
to it, so the documented `bannerBuilder` contract had no way to be
asserted on a host test runner. Add `debugUpdateCycleOverride`, a
`@visibleForTesting` seam that takes over the three calls the overlay
makes on its own behalf (init, resume re-check, dispose) so a test can
fire the overlay's own `onUpdateReady` directly. When it is unset the
overlay behaves exactly as before.

Also folds the four duplicated update-ready/dismiss closures into
`_markUpdateReady` / `_dismissBanner`; the dismiss path now carries the
`mounted` guard the ready path already had, since a custom banner may
call the handed `onDismiss` after unmount.

No update-flow, platform-channel or wire-format change.
#31)

Fourteen `testWidgets`/`test` rows against the seam from the previous
commit, pinning what the docs already promise:

- a custom `bannerBuilder`'s widget is what renders once an update is
  ready, and it replaces the default banner rather than adding to it;
- the handed `onDismiss` removes the banner, and the slot comes back for
  the next update;
- the documented "show no banner" recipe — returning
  `const SizedBox.shrink()` — renders nothing hit-testable (note the
  banner slot is a left/right-anchored `Positioned`, so the box is
  stretched to full width: zero HEIGHT is what makes it inert);
- the default banner renders and its LATER button dismisses;
- config resolution: explicit `config:` wins, `CodePush.lastConfig` is
  the fallback, and neither is an actionable `StateError`;
- the debug bar tracks `CodePush.status` and retires on the
  `statusPatchActive` edge, which latches and does not un-latch.

Plus two rows for #31: the constant keeps its app-facing wire value, and
it is the only place in the library source where the literal appears
outside doc-comment prose — so a future status-text edit cannot move one
writer without the others.

Closes #29. Closes #31.
Two `CodePush.init` calls leave two live update chains: the epoch guard
covers every async gap before the flow starts, but the flow's own
continuation does not carry the epoch, so the FIRST chain wins the
single-flight guard. An app that calls `init(onUpdateReady:)` in
`main()` and then wraps `runApp` in `CodePushOverlay` therefore gets its
own callback, not the overlay's banner.

The test tells the two sessions apart by `app_id` on the /updates query
against a loopback server, and asserts only the newest session checks.
Verified to reproduce: without the fix the captured request carries the
SUPERSEDED session's app_id.

It is committed SKIPPED. The fix belongs in the update flow itself
(threading the captured epoch into the `_startUpdateFlow` continuation
and bailing before the iOS reload, the launch timer, the quarantine and
rollback-report side effects, and the first check), which is out of
scope for this widget-layer branch and needs review of its interaction
with the three-strike gate. Removing the `skip:` is the acceptance
criterion.

Refs #30.
…ersedes an in-flight one

The crash-protection .then continuation ran to completion for a
superseded session, taking the single-flight guard with the stale
serverUrl/appId and starving the live session's first check. Thread
the init epoch into _startUpdateFlow and re-check it at the
continuation entry and after every await (the same discipline init's
store-install branch documents). A superseded or disposed session now
also never starts the launch-success timer (gate 5).

Un-skips test/init_supersede_test.dart — the reproduction harness is
the acceptance criterion.
@claude

claude Bot commented Sep 2, 2026

Copy link
Copy Markdown

🔴 Critical

None. Nothing here can crash or brick a host app, and the public API changes are purely additive (CodePush.statusPatchActive keeps the exact wire value 'Patch active', debugUpdateCycleOverride defaults to null, so production behavior is byte-identical to today).

🟠 Medium

1. The epoch guard runs after _runCrashProtection(), so a superseded session still increments the iOS boot counter — lib/src/code_push.dart:386

The guard is the first statement inside .then(...) (code_push.dart:394), but _runCrashProtection() has already completed by then and it is not side-effect-free: on iOS it does _iosIncrementBootCounter(patchDir) (code_push.dart:2219) whenever a patch file exists. Two init() calls in one launch — exactly the init-in-main() + CodePushOverlay combination #30 is about — therefore write boot_count twice for a single boot, against _maxBootAttempts = 3 (code_push.dart:114).

Failure scenario: iOS, a good patch installed, app calls CodePush.init in main() and mounts CodePushOverlay. The user opens the app and backgrounds/kills it inside the 10s _launchGracePeriodSeconds window twice, so _reportLaunchSuccess/_iosResetBootCounter never runs. boot_count reaches 4 instead of 2, _iosCheckAndAutoRollback trips on the third launch, and a perfectly healthy patch is auto-rolled-back and quarantined. Pre-existing, but it sits squarely inside the race this PR sets out to close, and the epoch guard cannot undo an increment that already happened. A per-process latch on _runCrashProtection (run at most once per launch) would close it and would also stop the two chains from racing the same counter file.

2. The epoch does not survive into checkAndInstall, so the supersede is still lossy once the stale chain has taken the single-flight guard — lib/src/code_push.dart:445

_startUpdateFlow re-checks the epoch after every await (394, 408, 430) — that part is correct, and I traced both platform branches: there is no un-guarded await between the last check and the checkAndInstall call. But checkAndInstall itself carries no epoch. If the older session has already entered it, the newer session's own call hits if (_checkInFlight) (code_push.dart:509), stamps 'A check is already running', and returns false — with no retry anywhere (the only other triggers are _timer, default 4h, and an app resume).

Failure scenario: app calls CodePush.init(onUpdateReady: appCallback) in main(), and CodePushOverlay mounts later than the first frame (splash screen, deferred route, await before runApp) — late enough that chain #1 is already past _getPatchDir() and inside the network check. The stale chain installs the patch and fires the stale onUpdateReady; the overlay's first check returns false immediately, and since the patch is then "already installed", the overlay's banner never appears for it. Same user-visible symptom #30 describes, just from a later mount. Narrower trigger than the fixed case, but worth either threading the epoch into checkAndInstall or having a losing caller re-arm once the guard clears.

🟡 Low

  • test/init_supersede_test.dart:20-23 — the file's doc comment still says the test "is deliberately SKIPPED" and that "Removing the skip: is the acceptance criterion for CodePushOverlay does not actually supersede an earlier init(onUpdateReady:) — unguarded .then chain races #30". There is no skip: argument in the file (the only occurrence of the token is inside that comment), and the PR body says the harness is un-skipped. The prose now contradicts both the code and the PR description.
  • test/init_supersede_test.dart:80 — synchronization is a bare await Future.delayed(Duration(milliseconds: 600)). The chain being waited on includes platform-channel round trips and a real loopback HTTP request; on a loaded CI runner an overshoot makes expect(requests, isNotEmpty) fail and reads as a regression in the fix rather than a slow machine. Completing a Completer from the server's listen callback and awaiting it with a generous timeout would be deterministic and faster.
  • lib/src/code_push.dart:2671dispose() decides whether to call the process-global CodePush.dispose() by re-reading the static debugUpdateCycleOverride at teardown time, not by recording whether this State actually started the live cycle. The two reads can disagree: an override installed after a live initState silently skips teardown (leaked _timer/_launchTimer), and an override cleared before the tree unwinds tears down a cycle this widget never started. Capturing a final bool _usedOverride in initState makes it self-consistent. Harmless in production (the static is always null), so this is test-suite robustness only.
  • lib/src/code_push.dart:2594 / 2680 — the seam collapses three distinct calls (init in initState, checkAndInstall on resume, and the skipped dispose) into one (config, onUpdateReady) callback, so a test cannot tell an init from a resume check. The new resume branch (2678-2686) and the dispose skip branch have no coverage in code_push_overlay_test.dart — nothing pins that a resume re-invokes the cycle. A discriminator argument (or a second callback) would make both assertable.
  • test/code_push_overlay_test.dart, source-scan test — its reason says "the library source" but it only reads lib/src/code_push.dart; widget_renderer.dart and models.dart are unscanned (both clean today, so this is future-proofing). The !line.startsWith('//') filter also lets a trailing // ... 'Patch active' comment through as a false positive.
  • CHANGELOG.md — the Unreleased section gains no entry for CodePush.statusPatchActive (new public API) or the supersede fix, even though the existing Unreleased bullet about "Combining CodePush.init(...) in main() with CodePushOverlay can install the first patch with no banner" is exactly what this PR narrows.

🟢 Positives

  • The epoch threading is done with real discipline: re-checked at continuation entry and after every await, with a comment at each site explaining what the stale chain would otherwise do. The periodic _timer assignment is also safe under interleaving because init cancels before bumping the epoch.
  • A welcome side effect nobody claimed: init(disableOnPlayStoreInstalls: true) superseding an earlier plain init now actually keeps OTA off, where previously the older chain would have gone on to checkAndInstall anyway.
  • test/init_supersede_test.dart is a genuine regression harness, not a tautology — it distinguishes the two sessions by app_id on the wire, so it fails pre-fix (stale chain wins the single-flight guard and sends superseded-main) and passes post-fix. Using a real loopback server rather than mocking the HTTP layer is the right call for a race like this.
  • Promoting 'Patch active' to a constant is backed by an enforcement test rather than trust, so the literal cannot creep back into executable code — the part of an "extract a constant" change that usually gets skipped. The value is unchanged, so there is no wire or API break for existing users.
  • The _markUpdateReady/_dismissBanner hoist is behavior-preserving, and the doc comment on _dismissBanner correctly calls out that mounted is not implied when a custom banner invokes onDismiss long after the frame that built it.
  • code_push_overlay_test.dart pins the real contract, including the documented SizedBox.shrink() recipe (asserting zero height and non-hit-testability, with a comment explaining why zero-by-zero is the wrong assertion for a left/right-anchored Positioned) and the dismiss → re-offer cycle.

…eview Lows

- M1: crash protection (iOS boot counter) now runs at most once per
  process via a latch - two inits per launch no longer double-increment
  the three-strike counter (test hook: debugResetCrashProtectionLatch).
- M2: checkAndInstall is epoch-aware at its two network chokepoints, so
  a chain superseded mid-check cannot download or install with stale
  parameters; a LIVE caller that lost the single-flight guard to a
  STALE in-flight check re-arms once when the guard clears (same-epoch
  overlaps keep the pinned quiet-loser semantics).
- Lows: harness doc-comment brought in line with reality; harness sync
  is now completer-based (no fixed sleep); the overlay captures its
  update-cycle mode in initState so resume/dispose cannot disagree with
  a mid-lifetime seam change; the constant scan covers every lib source
  file and strips trailing comments; CHANGELOG gains the two
  user-facing entries.

Suite 170/170.
@fonkamloic

Copy link
Copy Markdown
Contributor Author

Round-1 response: both Mediums fixed (crash-protection per-process latch; epoch-aware checkAndInstall + stale-loss re-arm — scoped to live-vs-stale losses so the pinned quiet-loser semantics for same-session overlaps are unchanged). Lows 1/2/3/5/6 fixed. Low 4 (seam call-kind discriminator + resume-branch coverage) tabled as a tracked follow-up: it is test-infrastructure ergonomics with no production surface; noting here per the gate.

@claude

claude Bot commented Sep 2, 2026

Copy link
Copy Markdown

Round-2 review. The round-1 Mediums and Lows 1/2/3/5/6 are genuinely fixed — I re-read the re-arm, the crash-protection latch, the _cycleOverride capture, and the Completer-based sync in init_supersede_test.dart, and all four do what the response claims. Low 4 is tabled per the gate and is not re-raised here. Two new Mediums below, both from reading the post-download half of checkAndInstall, which round 1 did not reach.

🔴 Critical

None. Nothing added here can crash or brick a host app: every new epoch check is a plain return false, _runCrashProtectionImpl is wholly inside try/catch (lib/src/code_push.dart:2277-2291) so the memoized future can never complete with an error, and the API surface is purely additive (statusPatchActive keeps the wire value 'Patch active'; debugUpdateCycleOverride and debugResetCrashProtectionLatch both default to inert).

🟠 Medium

1. The supersede window is closed at the two network chokepoints but not across the install, so a stale chain can still fire the stale onUpdateReadylib/src/code_push.dart:786962

The post-download guard at 786-789 is the last epoch check in the method. After it the Android path runs await installPatch(patchBytes) (946) and await _getPatchDir() (953) before status.value = 'Restart to apply'; onUpdateReady?.call(); (961-962). iOS has the same shape at 872/917913. A supersede landing inside those awaits therefore reaches the callback, and the comment at 578-582 ("the two network chokepoints ... bound every irreversible side effect in this method") is not quite true — dispatching the update-ready notification is a side effect that outlives them.

Failure scenario: main() calls CodePush.init(..., onUpdateReady: appCb) (epoch 1); its chain takes the guard and gets to installPatch with a multi-MB patch. CodePushOverlay mounts during that write (splash screen / deferred route) → init epoch 2; its checkAndInstall loses the guard and correctly registers a re-arm (521-535). The stale chain finishes the install and calls appCb, not the overlay's _markUpdateReady. The re-arm then fires a live check, which hits the already-installed short-circuit at 679-686status.value = 'Patch already installed', return false, no onUpdateReady. Net result: patch installed, no banner. That is issue #30's exact user-visible symptom, surviving the fix through a narrower door (the install window rather than the whole flow).

The re-arm cannot heal this by construction, because the second check legitimately has nothing to offer. A fix needs the notification to be re-deliverable: e.g. a process-level "an install this session is pending restart" latch that the already-installed branch consults and re-announces to the current caller's onUpdateReady. A bare epoch check before 962 would suppress the wrong callback but still leave no banner.

2. Both round-1 concurrency fixes ship with zero test coverage — lib/src/code_push.dart:521-535, 2263-2273

grep for debugResetCrashProtectionLatch, _inFlightEpoch, and Check superseded across test/, example/, README.md, CHANGELOG.md returns nothing. Specifically:

  • init_supersede_test.dart exercises only the pre-check supersede: two inits in the same synchronous turn, so chain 1 dies at 394 and the re-arm branch at 521 never executes. Nothing anywhere drives a live caller losing to a stale in-flight check, which is the entire point of _checkDone/_inFlightEpoch.
  • debugResetCrashProtectionLatch (2270-2273) is added @visibleForTesting and never called. The latch it exists to reset is likewise untested — and it cannot be tested incidentally, because _runCrashProtectionImpl returns at 2276 on any non-iOS host, so no host test ever enters the body whose double-execution the latch prevents.

That leaves the two riskiest additions in the PR — process-global mutable statics with epoch-dependent branches, and a per-process latch on a rollback counter — resting on review rather than on a gate. The re-arm in particular is testable with the same loopback harness init_supersede_test.dart already builds: hold the server's first response open, init again mid-flight, release, and assert a second request arrives carrying the new app_id. Given #30 was itself a race that only a harness caught, the asymmetry is worth closing.

Merge gate: two open Mediums. Per policy this may merge only if each unfixed one is filed as its own issue labelled deferred-medium first. I have not filed them — that is the author's call.

🟡 Low

  • lib/src/code_push.dart:523entryEpoch == _initEpoch in the re-arm guard is a tautology: entryEpoch is assigned at 509 and there is no await between there and 523, so the two can never differ. Harmless, but it reads as a real check and invites a future reader to assume an async gap exists there.
  • lib/src/code_push.dart:478-486checkAndInstall's public status contract still documents the loser as purely quiet ("stamps 'A check is already running' and returns false without disturbing the active check's terminal message"). It can now also schedule a retry that writes status after the winner returns, so a caller reading status after await winner may see the retry's writes. Worth a sentence in that doc block.
  • lib/src/code_push.dart:584'Check superseded by a newer init' is a new user-visible status value, but README.md:246-250's enumeration and the CHANGELOG entry do not mention it. Apps that switch on status strings (the README explicitly invites reading them) get an unlisted value.
  • lib/src/code_push.dart:983_inFlightEpoch is never reset when a check completes, so it stays pinned to the last acquirer. Combined with dispose() bumping _initEpoch (467), a check that took the guard before an overlay unmount makes any subsequent caller eligible for a re-arm: the caller gets false, and a duplicate round-trip fires later that it never requested and cannot observe. Resetting it alongside _checkDone in the finally (968-972) would scope the re-arm to the case the comment describes.
  • test/code_push_overlay_test.dart:265 — the source scan truncates each line at the first //, which also truncates inside string literals (any 'https://…' line is cut short). Nothing today puts 'Patch active' after a URL on one line, so it is a latent false negative rather than a bug — noting it only because the test's value is that it cannot be quietly defeated.

🟢 Positives

  • The epoch threading is disciplined rather than decorative: re-checked at continuation entry and after every await (394, 408, 430), each with a comment naming the specific side effect a stale chain would otherwise cause. I traced both platform branches — there is no unguarded await between 430 and the checkAndInstall at 445.
  • The crash-protection latch (2263-2266) fixes a real, pre-existing iOS defect the round-1 review surfaced: two inits in one launch double-incrementing boot_count against _maxBootAttempts = 3 could auto-roll-back a healthy patch. Memoizing the future rather than short-circuiting also preserves the load-bearing ordering documented at 402-406 for the second chain.
  • The re-arm is correctly scoped to live-vs-stale (522-524), so the long-standing quiet-loser semantics that hardening_test.dart:169-204 pins are untouched, and the ordering in the finally (_checkInFlight = false at 969 before _checkDone.complete() at 970) means the retry genuinely finds the guard released. The "cannot stampede" claim holds: concurrent re-arms serialize on the guard, and the second one sees _inFlightEpoch == _initEpoch and stays quiet.
  • init_supersede_test.dart is a real regression harness, not a tautology — it distinguishes sessions by app_id on the wire, so it fails pre-fix. The round-1 flakiness note was taken seriously: Completer + 30s timeout + a short settle window is deterministic in both directions (it still catches a stale request arriving first).
  • _cycleOverride captured in initState (2704) and consulted by both dispose (2749) and resume (2760) is the right shape — the static and the capture can no longer disagree and tear down a cycle the widget never started.
  • Promoting 'Patch active' to a constant is backed by an enforcement scan over every lib/**.dart file rather than by trust, with the value pinned separately ("keeps the app-facing wire value") so the wire contract can only change deliberately. No break for existing users.
  • The SizedBox.shrink() test asserting zero height and non-hit-testability — with a comment explaining why zero-by-zero is the wrong assertion for a left/right-anchored Positioned — is the kind of detail that keeps a widget test honest.

…cy coverage

- M1: the install window no longer leaks the notification to a
  superseded session. Both install tails set a per-process
  pending-restart latch and fire onUpdateReady only for a current
  session; the already-installed branch re-announces to a live caller
  while an install from this session still awaits its restart (the
  re-arm's second check now produces the banner instead of a silent
  'Patch already installed'). Latch cleared on rollback and on the
  server-withdrawal convergence branch.
- M2: both round-1 mechanisms now have direct coverage — a held-open
  loopback response proves a mid-flight supersede re-arms exactly one
  live check (no stampede), and a run counter proves the boot-counter
  latch admits one crash-protection run per process across inits.
- Low: removed the tautological epoch conjunct from the re-arm guard.

Suite 172/172.
@claude

claude Bot commented Sep 2, 2026

Copy link
Copy Markdown

Round-3 review. The two round-2 Mediums are addressed and I verified each: the install-window door is closed end-to-end (stale chain installs → _installPendingRestart = true at lib/src/code_push.dart:980 → re-arm's live check lands on the already-installed branch → onUpdateReady fires for the live session at 692), and the concurrency additions now have real gates (init_supersede_test.dart:110-159 for the re-arm, 162-199 for the crash-protection latch, debugCrashProtectionRuns bumped before the platform check so it is observable on a host runner). Round-2 Low 1 is gone — the guard is now _inFlightEpoch != _initEpoch (522), not the tautology.

One new Critical below, which is a side effect of the round-2 Medium-1 fix.

🔴 Critical

1. _installPendingRestart is a sticky level, so onUpdateReady now re-fires on every check until restart — lib/src/code_push.dart:692-694, 980, 925

The latch is set on install and cleared only by rollback (1847) and the server-withdrawal convergence branch (914). It is never cleared by the re-announcement itself. Every later check that reaches the already-installed branch therefore calls onUpdateReady again — and reaching it is the designed-for case, not an edge: the comment at 668-676 says the server keeps re-offering the same patch, and _isPatchAlreadyInstalled (1256-1271) matches as long as the bytes and installed_patch_identity.json are on disk, which they are until restart.

Both automatic entry points pass onUpdateReady through:

  • the periodic timer (454-462, default 4h),
  • CodePushOverlay's resume check (2805-2811, onUpdateReady: _markUpdateReady).

Failure scenario A (overlay): Android, patch installs, banner appears, user taps LATER → _dismissBanner sets _updateReady = false (2769-2771). User backgrounds and resumes the app → resume checkAndInstall → same offer → 'Patch already installed'_installPendingRestart still true, epoch matches → _markUpdateReady (2762) → _updateReady = true, banner is back. The user cannot dismiss it for the rest of the session. That directly contradicts the dismiss semantics this PR itself pins in test/code_push_overlay_test.dart:98-121 ("a dismissed banner comes back for the next update").

Failure scenario B (app-supplied callback, the public-API break): README.md:120-122 documents onUpdateReady as "Called when a patch is installed and a restart is needed", and code_push.dart:286-287 says the same. Apps that do onUpdateReady: () => showDialog(...) now get that dialog re-raised on every resume and every 4h tick. Worse, the new call site sits on a path that returns false (695), so checkAndInstall can now invoke onUpdateReady and return false — which the package's own example treats as mutually exclusive: example/lib/main.dart:79-96 sets 'Patch installed! Restart to apply.' in the callback and then, because installed == false, immediately overwrites it with CodePush.status.value ('Patch already installed'). Existing users get contradictory signals from one call.

The #30 door only needs the notification to survive once, to the first live caller that lands on that branch. Clearing the latch after re-announcing (if (_installPendingRestart && entryEpoch == _initEpoch) { _installPendingRestart = false; onUpdateReady?.call(); }) closes the door without turning a one-shot edge into a repeating level. If repeat delivery is genuinely wanted, it needs to be opt-in and documented, and the false-return/callback pairing in README.md:141-159 and the example needs updating with it.

🟠 Medium

1. The re-arm regression test passes whether or not the re-arm runs — test/init_supersede_test.dart:110-159

Synchronization between the live init (136) and releaseFirst.complete() (145) is a bare await Future.delayed(300ms) (144). Nothing asserts that the live chain actually reached checkAndInstall and lost the guard within that window. If it hasn't (loaded CI runner — the chain has to get through _runCrashProtection, _iosReloadInstalledPatch and _getPatchDir first), the stale check completes, the guard clears, and the live chain then runs its own check directly. Both assertions still hold: requests.last is 'live-owner' and there is exactly one such request. So the outcome the test checks is produced identically by the path the test is meant to exclude — it is not a gate for the re-arm, only for "the live session eventually checks".

Making it discriminating is cheap: after the 300ms window and before releasing, assert CodePush.status.value == 'A check is already running' — that write (511) happens only on the losing branch and proves the re-arm was registered. Better still, complete a Completer from the losing branch under a debug hook so the release is event-driven, the same treatment firstRequest already gets (99, 140). Same bare-delay pattern is load-bearing at 148, 183 and 195.

2. _installPendingRestart — the riskiest new static — has no test at all — lib/src/code_push.dart:1005

grep -rn "PendingRestart" test/ returns nothing. test/reoffer_and_devicehash_test.dart:214-226 is the only test that drives the already-installed branch end-to-end and it passes no onUpdateReady, so the new call site at 692-694 is never executed by the suite. Nothing pins the behavior the fix exists for (a live caller landing on already-installed does get notified), nothing pins that rollback clears it (1847), and nothing would have caught the repeat-firing in Critical 1. The reoffer harness already builds the exact fixture needed — patch bytes plus identity plus a re-offer — so adding a callback counter to it is a small change.

🟡 Low

  • lib/src/code_push.dart:582, README.md:247-249, CHANGELOG.md:3-8 — still open from round 2: 'Check superseded by a newer init' is a new user-visible status value and is in neither the README enumeration nor the CHANGELOG. The CHANGELOG also does not mention the auto-retry or the re-announcement, both of which change observable behavior for existing users.
  • lib/src/code_push.dart:478-486 — still open from round 2: the checkAndInstall status contract still describes the loser as purely quiet. It can now schedule a retry that writes status after the winner returns (523-532), so a caller reading status after await winner may see the retry's writes.
  • test/code_push_overlay_test.dart:265-269 — still open from round 2: the scan truncates each line at the first //, which also truncates inside string literals, so a 'Patch active' occurring after a URL on the same line is a false negative.
  • test/init_supersede_test.dart:53-57server.listen's callback calls handler(req) without awaiting it. In the held-response test the handler suspends on releaseFirst.future; if it ever threw (e.g. the client hung up first, so response.close() fails) the error surfaces as an unhandled async exception rather than a test failure. unawaited(handler(req).catchError(...)) would make the intent explicit.
  • lib/src/code_push.dart:989-991_checkDone is completed in the finally, which on the iOS post-download path runs while _iosLoadPayload is still in flight (documented at 483-486). A re-arm can therefore start a fresh check during that load. Pre-existing shape, but the re-arm makes it reachable without a second caller; worth a sentence in the _checkDone doc so the next reader does not assume completion means "load finished".

🟢 Positives

  • The install-window fix is the right shape: the notification is made re-deliverable rather than the callback merely suppressed, so the live session actually gets a banner instead of silently getting nothing. It is also correctly cleared on both paths that invalidate it — rollback (1847) and server-withdrawal convergence (914) — which is the part that is easy to forget.
  • _installPendingRestart is set before the epoch-gated callback at 980/982 and 925/927, so the stale chain still records the fact of the install even though it must not announce it. The ordering is what makes the re-arm's re-announcement possible at all.
  • The crash-protection latch (2295-2298) is memoized rather than short-circuited, so the second chain still awaits completion and preserves the load-bearing ordering documented at 399-427. debugCrashProtectionRuns bumped before the Platform.isIOS early return (2315-2316) is what makes it testable on a host runner — the counter is a deliberate seam, not incidental.
  • init_supersede_test.dart:162-199 tests the latch and its reset in one case (two inits → one run; reset → exactly one more), so the test-only escape hatch cannot silently stop working.
  • The re-arm's scoping (522) leaves the long-standing quiet-loser semantics that test/hardening_test.dart:169-231 pins completely untouched: any direct checkAndInstall caller sees _inFlightEpoch == _initEpoch because the winner assigns it synchronously at 537, with no await in between.
  • Epoch threading through _startUpdateFlow remains disciplined — re-checked at continuation entry and after every await (394, 406, 430), each with a comment naming the specific side effect a stale chain would otherwise cause.
  • _cycleOverride captured in initState (2744) and consulted by both dispose (2789) and resume (2800) means the static and the capture can no longer disagree and tear down a cycle this widget never started.
  • The statusPatchActive extraction is backed by a scan over every lib/**.dart file with the wire value pinned separately, so the constant cannot drift and cannot break existing users.

Merge gate: 1 Critical → blocks. The two Mediums are moot until the Critical is resolved, since the fix and its coverage land together.

…r both mechanisms

- CRITICAL: the pending-restart latch was a sticky level - every
  periodic/resume check re-fired onUpdateReady until restart. The
  announcement is now edge-triggered per session (announced-epoch
  marker): one delivery per session, whether at install time or via the
  already-installed re-announce; rollback and the convergence branch
  reset it.
- The re-arm test now PROVES the re-arm ran: it requires the live chain
  to observably lose the guard ('A check is already running') while the
  stale request is still held, closing the pass-without-re-arm hole.
- The latch itself is tested end to end through the already-installed
  branch (staged on-disk identity + matching offer): silent without a
  pending install, exactly one announcement per session, no re-fire on
  later checks, one more for a genuinely new session, silent after the
  rollback-path clear.

Suite 173/173.
@claude

claude Bot commented Sep 2, 2026

Copy link
Copy Markdown

Round-4 review. The round-3 Critical is fixed and I traced it end to end: _pendingRestartAnnouncedEpoch (lib/src/code_push.dart:1024) makes the re-announcement a per-session edge, consulted at 693-698, and test/init_supersede_test.dart:182-249 pins all four transitions. The round-3 Mediums are closed too — the re-arm test now has a real discriminator (liveLost plus expect(held, 1), init_supersede_test.dart:141-160) instead of a bare delay.

🔴 Critical

None. Every new epoch check is a plain return false, _runCrashProtectionImpl keeps its whole body in try/catch (2347-2361) so the memoized future cannot complete with an error and stall every later init chain, and the API surface stays additive.

🟠 Medium

1. The pending-restart announcement is one token per SESSION but delivered to one CALLER — lib/src/code_push.dart:693-698, 990-993, 932-935

All three sites stamp _pendingRestartAnnouncedEpoch = _initEpoch unconditionally, then call onUpdateReady. The marker is process-global and keyed only on the epoch, so the first caller in a session to reach any of the three consumes that session's only announcement — including when its onUpdateReady is null, in which case the session is recorded as announced although nothing was announced.

Failure scenario: an app uses CodePushOverlay (its initState owns epoch N with onUpdateReady: _markUpdateReady, 2779-2787) and also has a "Check for updates" button calling CodePush.checkAndInstall(...) directly — the pattern README.md:141-159 documents and example/lib/main.dart:72-101 ships. The button's check wins the guard and installs on Android (988-993); entryEpoch == _initEpoch, so the marker is stamped N and the button's callback fires, or nothing fires at all since onUpdateReady is optional there. The overlay's _markUpdateReady is never called, so _updateReady stays false, and every later overlay check — resume (2835-2841) and the periodic timer (454-462) — lands on the already-installed branch and is suppressed by _pendingRestartAnnouncedEpoch == _initEpoch (695). Patch installed, no banner, for the rest of the session: #30's exact symptom, through the door the new latch opens.

The null-callback half is a plain bug in any configuration and is cheap to close: only stamp the marker when there is a callback to stamp it for. The multi-caller half needs "who has been told" to be per-listener rather than per-epoch — e.g. a level the overlay can read rather than an edge only one caller can consume.

2. onUpdateReady can now fire on a false return; the contract and the shipped example were not updated — lib/src/code_push.dart:476, 692-699; README.md:143-159; example/lib/main.dart:75-95

Before this PR onUpdateReady fired only on the two return true paths (936, 994). The new call site at 697 sits on a path returning false at 699. Deliberate, but it changes an existing public callback's contract and nothing documenting it moved: the dartdoc still says only "Returns true if a patch was installed (restart needed)" (476), init's doc still says "When a patch is installed, [onUpdateReady] is called" (286-287), and README.md:143-144 repeats it.

In the package's own example: _manualCheck sets the status to "Patch installed! Restart to apply." inside onUpdateReady (79-86), then — because installed == false — overwrites it with "No new patch installed: Patch already installed" (88-95). One call, two contradictory user-facing messages. Any app pairing the callback with the return value the way the example and README teach has the same bug. Round 3 flagged this pairing as part of Critical 1; the repeat-firing half was fixed, this half was not.

Merge gate: two open Mediums. Per policy this may merge only if each unfixed one is first filed as its own issue labelled deferred-medium. I have not filed them — that is the author's call.

🟡 Low

  • README.md:341-347, CHANGELOG.md:22-25 — both still describe CodePushOverlay does not actually supersede an earlier init(onUpdateReady:) — unguarded .then chain races #30 as an open known issue ("usually installs the first patch with no banner that session"), contradicting the new Unreleased bullet three lines above.
  • lib/src/code_push.dart:582, 800; README.md:247-249 — still open from rounds 2/3: "Check superseded by a newer init" is a new user-visible status value, in neither the README enumeration nor the CHANGELOG.
  • lib/src/code_push.dart:481-486, README.md:257-259 — still open: the status contract still calls the losing caller purely quiet. It can now schedule a retry (521-533) that writes status after the winner returns.
  • lib/src/code_push.dart:2772-2774_cycleOverride is captured after final cfg = _config;, and _config throws when no config exists (2758-2763). On that path _cycleOverride stays null, so dispose() calls the process-global CodePush.dispose() (2819) even though the seam was installed — exactly what the seam doc promises it will not do. The new "no config and no prior init throws" test walks that path and bumps the global epoch. Reading the static one line earlier fixes it.
  • test/code_push_overlay_test.dart:272-273 — still open: the scan truncates each line at the first //, including inside string literals, so the literal after a URL on one line is a false negative.
  • test/init_supersede_test.dart:53-57 — still open: server.listen calls handler(req) unawaited, so a throw in the held-response handler surfaces as an unhandled async exception rather than a test failure.
  • lib/src/code_push.dart:999-1003 — still open: _checkDone completes in the finally, which on the iOS post-download path runs while _iosLoadPayload is still in flight (958). A re-arm can start a fresh check during that load; worth a sentence on _checkDone.
  • lib/src/code_push.dart:316 vs 411-414 — the new comment justifies gate 5 with "dispose() has already cancelled any running launch timer". True for dispose(), but init() cancels only _timer, not _launchTimer. If an earlier chain reached _startLaunchTimer() (415) and the superseding init takes the store-install branch (326-349), nothing replaces that timer and the stale session still reports launch success. Benign today, but the comment claims more than the code delivers.

🟢 Positives

  • The round-3 fix is the right shape, not the cheap one: per-session edge rather than a sticky level or a one-shot a remount could never re-arm, with both invalidating paths cleared — rollback (1876-1877) and server-withdrawal convergence (918-919).
  • test/init_supersede_test.dart:182-249 is a real gate: it stages patch bytes plus installed_patch_identity.json and serves a matching offer so the already-installed branch is deterministic, then pins silence, one, none, one-per-new-session, silence-after-clear. It would have caught the round-3 bug directly.
  • The re-arm test's discriminator (141-160) is done properly — a status listener completing liveLost plus expect(held, 1) proving the stale request was still held when the live chain lost the guard. The path the test excludes can no longer produce a pass.
  • The re-arm's scoping (522) is still correct after all the churn: a same-epoch loser sees _inFlightEpoch == _initEpoch because the winner assigns it synchronously at 537 with no await between, so the quiet-loser semantics test/hardening_test.dart pins are untouched and concurrent re-arms serialize rather than stampede.
  • _installPendingRestart is set before the epoch-gated callback at 930/932 and 988/990, so a stale chain records the install even though it must not announce it — the ordering that makes re-delivery possible at all.
  • The crash-protection latch is memoized rather than short-circuited (2327-2328), preserving the load-bearing ordering documented at 402-406, and the run counter is bumped before the Platform.isIOS early return (2345-2346) so the guarantee is observable on a host runner.
  • Epoch threading through _startUpdateFlow stays disciplined — re-checked at continuation entry and after every await (394, 408, 430). I re-traced both platform branches: no unguarded await between 430 and the checkAndInstall at 445.
  • statusPatchActive is enforced by a scan over every library source file with the wire value pinned separately (test/code_push_overlay_test.dart:251-285), so the constant cannot drift and existing users cannot break.

…rn contract

- A caller passing no callback no longer consumes the session's one
  pending-restart announcement - the next caller that can hear it gets
  it. Multi-listener UIs are pointed at status/moduleResult in the
  contract docs.
- The 'onUpdateReady can fire on a false return' behavior is now part
  of the documented contract (checkAndInstall doc, README, example
  comment).
@claude

claude Bot commented Sep 2, 2026

Copy link
Copy Markdown

Round-5 review. The round-4 work is in — checkAndInstall's dartdoc documents the false-return announcement (lib/src/code_push.dart:478-486), README.md:161-166 mirrors it, and the re-announce site is callback-gated (705-711). But round-4 Medium 1 and Medium 2 are each half fixed, and the unfixed halves carry the user-visible consequence. Round-4 Lows 1/2/3/5/6/7/8 are still open verbatim; re-listed compactly rather than re-argued.

🔴 Critical

None. Every new epoch check is a plain return false; _runCrashProtectionImpl keeps its whole body in try/catch (2357-2375) so the memoized future cannot complete with an error and stall every later init; _checkDone?.complete() cannot double-complete (1013-1015 has no await between clearing the guard, completing, and nulling); the API surface stays additive.

🟠 Medium

1. The two install sites still stamp the session's announcement token when onUpdateReady is nulllib/src/code_push.dart:1003-1006, 945-948

Round 4 flagged that all three sites stamp _pendingRestartAnnouncedEpoch unconditionally. The fix added onUpdateReady != null only at the re-announce site (706); both producing sites still do _pendingRestartAnnouncedEpoch = _initEpoch; onUpdateReady?.call(); inside a bare if (entryEpoch == _initEpoch), so a null callback consumes the token and announces nothing.

Failure scenario: an app mounts CodePushOverlay (its initState owns epoch N with onUpdateReady: _markUpdateReady, 2792-2800) and also has a "Check for updates" button calling CodePush.checkAndInstall(serverUrl:, appId:, releaseVersion:)onUpdateReady is optional there and README.md:141-159 documents the call standalone. The button's check wins the guard and installs on Android: 1001 sets _installPendingRestart = true, 1004 stamps N, onUpdateReady?.call() is a no-op. Every later overlay check — resume (2848-2854) and the periodic timer (454-462) — lands on the already-installed branch and fails _pendingRestartAnnouncedEpoch != _initEpoch at 708, so _markUpdateReady never runs. Patch installed, no banner, rest of the session: #30's exact symptom, through the token the new latch introduces. Moving the stamp inside a null check, as 705-711 already does, closes it.

The multi-caller half is also still open, and the new docs' mitigation does not apply to the package's own widget: README.md:164-166 tells apps with several listeners to observe CodePush.status/moduleResult instead, but CodePushOverlay latches only statusPatchActive (2816-2822) and never reads 'Restart to apply'. When any other caller consumes the token first, the shipped overlay has no fallback to a banner.

2. The shipped example still demonstrates the contradiction its own new comment warns against — example/lib/main.dart:79-101

Round-4 Medium 2 said _manualCheck emits two contradictory user-facing messages from one call. The response added a comment (94-99) but left the code: 85 sets 'Patch installed! Restart to apply.' in onUpdateReady, then installed == false on this newly-reachable path so 100-101 overwrites it with 'No new patch installed: Patch already installed'. The comment says "treat the callback, not the return value, as the 'show the restart prompt' signal" and the four lines below it do the opposite. This is the package's reference implementation of the pattern the PR changes, so apps that copied it inherit the bug. A bool readyFired set in the callback and checked in the if (!installed) branch fixes it.

Merge gate: two open Mediums. Per policy this may merge only if each unfixed one is first filed as its own issue labelled deferred-medium. I have not filed them — that is the author's call.

🟡 Low

  • README.md:349-355, CHANGELOG.md:21-24 — still open from round 4: both still list CodePushOverlay does not actually supersede an earlier init(onUpdateReady:) — unguarded .then chain races #30 as an unresolved known issue, contradicting the new Unreleased bullet at CHANGELOG.md:3-7 eighteen lines above.
  • lib/src/code_push.dart:591-594, 812-815; README.md:254-256 — still open from rounds 2/3/4: 'Check superseded by a newer init' is a new user-visible status value, in neither the README enumeration nor the CHANGELOG.
  • README.md:264-266, lib/src/code_push.dart:481-486 — still open: both still call the losing caller purely quiet; the re-arm (531-543) can now schedule a retry that writes status after the winner returns.
  • lib/src/code_push.dart:286-287checkAndInstall's dartdoc was updated for the false-return announcement; init's was not ("When a patch is installed, [onUpdateReady] is called"), which is the incomplete half of the contract for the most common entry point.
  • lib/src/code_push.dart:2785-2787 — still open from round 4: _cycleOverride is captured after final cfg = _config;, and _config throws at 2771. On that path the capture stays null and dispose() calls the process-global CodePush.dispose() (2832) despite the seam — the one thing the seam doc promises it will not do. The "no config and no prior init throws" widget test walks it and bumps _initEpoch. Read the static one line earlier.
  • Coverage for the round-4 fix — test/init_supersede_test.dart:182-249 drives the already-installed branch only via debugSetInstallPendingRestart, always with a non-null callback. The new onUpdateReady != null guard (706) has no test, and neither install site's stamping (945-948, 1003-1006) is executed by the suite. The same harness covers it: one check() with no callback, then one with a callback, asserting the second still fires.
  • lib/src/code_push.dart:1013-1015 vs 971 — still open, but worth naming now that the retry is automatic: _checkDone completes in the finally, which on the iOS install path runs before _iosLoadPayload resolves, so a re-arm starts a fresh check during the load. If _getPatchDir() returned null, the already-installed short-circuit at 687 cannot fire (it needs quarantineDir != null) and no identity was recorded at 957-969, so the retry re-downloads and loads the same payload twice — the double-load the guard comment at 505-507 says reverts the copy just loaded. Narrow, but it is the one path where the re-arm can do harm rather than waste a round trip.
  • test/init_supersede_test.dart:53-57 — still open: handler(req) is unawaited, so a throw in the held-response handler surfaces as an unhandled async exception, not a test failure.
  • test/code_push_overlay_test.dart:272-273 — still open: the scan truncates at the first //, including inside string literals, so the literal after a URL on one line is a false negative.

🟢 Positives

  • The round-4 contract fix went to the right places: the dartdoc (478-486) states the false-return announcement, its per-session cardinality and the null-callback rule, and README.md:161-166 mirrors it in user-facing prose. Documenting a callback that can now fire on a false return, rather than quietly shipping it, is what protects existing users.
  • The callback-gated re-announce (705-711) is right where it landed — a caller with no callback no longer burns the token on the branch that consumes it. Only the two producing sites were missed.
  • _installPendingRestart is still set before the epoch-gated callback (943/945, 1001/1003), so a stale chain records the install even though it must not announce it — the ordering that makes re-delivery possible at all, intact after four rounds of churn.
  • The latch is cleared on both invalidating paths — rollback (1889-1890) and server-withdrawal convergence (931-932) — including the _pendingRestartAnnouncedEpoch = -1 reset, so a later genuine install is not suppressed by a stale marker.
  • Epoch threading through _startUpdateFlow stays disciplined: re-checked at continuation entry and after every await (394, 408, 430), each with a comment naming the side effect a stale chain would cause. I re-traced both platform branches — no unguarded await between 430 and the checkAndInstall at 445.
  • The re-arm's scoping (532) still leaves the quiet-loser semantics untouched: a same-epoch loser sees _inFlightEpoch == _initEpoch because the winner assigns it synchronously at 547 with no await between. Concurrent re-arms serialize rather than stampede.
  • The crash-protection latch is memoized rather than short-circuited (2340-2341), preserving the load-bearing ordering at 402-406, and debugCrashProtectionRuns is bumped before the Platform.isIOS early return (2358-2359) so the once-per-launch guarantee is observable on a host runner — test/init_supersede_test.dart:251-287 pins the latch and its reset.
  • test/init_supersede_test.dart:109-180 is still a genuine race gate: the liveLost status listener plus expect(held, 1) proves the stale request was held when the live chain lost the guard, so the excluded path cannot produce a pass.
  • statusPatchActive is enforced by a scan over every lib/**.dart file with the wire value pinned separately (test/code_push_overlay_test.dart:251-285), so the constant cannot drift and existing users cannot break.

… example follows its own advice

Both install tails now stamp the session's announcement token only
when a callback actually receives it, matching the re-announce site's
rule from round 4. The example tracks whether onUpdateReady fired and
no longer clobbers the restart prompt with the false-return status.
@claude

claude Bot commented Sep 2, 2026

Copy link
Copy Markdown

Round-6 review. Round-5's two Mediums are fixed and I verified both: the producing install tails now stamp the announcement token only when a callback hears it (lib/src/code_push.dart:948-951, 1008-1011), matching the re-announce rule at 705-711; and example/lib/main.dart:80/107-110 tracks updateAnnounced so the false-branch no longer clobbers the prompt it just showed.

What is left is the half of round-4/5 Medium 1 that was never addressed — the token is per-SESSION but consumed by one CALLER — plus its coverage gap. Neither has been refuted in-thread. Round-5 Lows are re-listed compactly, not re-argued.

🔴 Critical

None. Every new epoch check is a plain return false; _runCrashProtectionImpl wraps its whole body in try/catch (2365-2379) so the memoized future cannot reject and stall every later init; _checkDone cannot double-complete (546-548 is the only writer and is reachable only when _checkInFlight is false; 1017-1021 has no await between clearing, completing and nulling); done.future never completes with an error, so the unawaited(...then(...)) re-arm at 531-542 cannot raise an unhandled async error. The API surface stays additive and no platform channel or wire format is touched.

🟠 Medium

1. The session's announcement token is consumed by whichever caller installs first — and the shipped example is the configuration that loses it. lib/src/code_push.dart:1008-1011, 948-951, 705-711

Both producing sites now require a non-null callback before stamping, but they stamp for their own caller and there is no second delivery. _pendingRestartAnnouncedEpoch is keyed on the epoch alone (708), so once any caller in session N is told, every other listener in session N is permanently suppressed.

CodePushOverlay has no fallback: _updateReady is written only by _markUpdateReady (2811), reachable only through onUpdateReady (2804 init, 2858 resume). Its other listener _onModuleLoaded (2821-2827) latches statusPatchActive only and never reads 'Restart to apply'. So the new docs' mitigation — "UIs with several listeners should observe status/moduleResult" (484-486, README.md:161-166) — does not apply to the package's own widget.

Reproducible with the example as shipped: example/lib/main.dart:15-16 wraps the app in CodePushOverlay (epoch N, onUpdateReady: _markUpdateReady), and _manualCheck (example/lib/main.dart:81-94) calls checkAndInstall(..., onUpdateReady: ...) from a button. The button's check wins the guard and installs on Android: 1004 sets _installPendingRestart = true, 1008-1010 stamps N and fires the button's callback. _markUpdateReady never runs. Every later overlay check — resume (2853-2859) and the periodic timer (454-462) — reaches the already-installed branch and fails _pendingRestartAnnouncedEpoch != _initEpoch at 708. Patch installed, no banner, rest of the session: #30's symptom, now through the token instead of the race.

Also worth having in view: re-delivery depends on the server still offering the same patchdata['patch_available'] != true returns at 629-631, well before the already-installed branch at 687 — so a server that stops offering once delivered gives the live session no second chance at all. The durable shape is a level the overlay can latch (e.g. a public ValueNotifier<bool> restartPending, the way moduleResult already works) rather than an edge exactly one caller can consume.

2. The round-5 token rule ships with no test. lib/src/code_push.dart:706, 948-951, 1008-1011; test/init_supersede_test.dart:214-219

grep -rn "onUpdateReady" test/ returns three hits; the only one passing a callback into checkAndInstall is init_supersede_test.dart:218, which is always non-null. So the onUpdateReady != null conjunct at 706 is never exercised with a null callback — delete it and the suite still passes — and neither producing site's stamp is executed by the suite at all: the latch test reaches the already-installed branch only via debugSetInstallPendingRestart (222, 227, 245), never through a real install.

That is the third consecutive round in which this one mechanism changed semantics (r3 sticky level, r4 unconditional stamp, r5 null-callback stamp), each caught by reading rather than by a gate. The fixture exists at init_supersede_test.dart:190-219; two rows close it — one checkAndInstall with no onUpdateReady asserting ready stays 0, then one with a callback asserting it still fires.

Merge gate: two open Mediums. Per policy this may merge only if each unfixed one is first filed as its own issue labelled deferred-medium. I have not filed them — that is the author's call.

🟡 Low

  • README.md:348-354, CHANGELOG.md:22-24 — still open (r4/r5): both still present CodePushOverlay does not actually supersede an earlier init(onUpdateReady:) — unguarded .then chain races #30 as a live known issue ("usually installs the first patch with no banner that session"), contradicting CHANGELOG.md:3-6 nineteen lines above. The most user-visible loose end left.
  • lib/src/code_push.dart:591-594, 812-815; README.md:254-256 — still open (r2-r5): 'Check superseded by a newer init' is a new user-visible status value, in neither the README enumeration nor the CHANGELOG.
  • lib/src/code_push.dart:481-486; README.md:263-266 — still open: both still call the losing caller purely quiet; the re-arm (531-542) can write status after the winner returns.
  • lib/src/code_push.dart:286-287 — still open (r5): checkAndInstall's dartdoc documents the false-return announcement, init's does not — and init is the more common entry point.
  • lib/src/code_push.dart:2790-2792 — still open (r4/r5): _cycleOverride is captured after final cfg = _config;, and _config throws at 2776. On that path the capture stays null, so dispose() calls the process-global CodePush.dispose() (2837) despite the seam. test/code_push_overlay_test.dart:197-207 walks exactly that path and bumps _initEpoch for every later test in the process. Read the static one line earlier.
  • lib/src/code_push.dart:1017-1021 vs 974 — still open: the finally completes _checkDone while _iosLoadPayload is in flight, so a re-arm can start a check during the load. Worth a sentence on _checkDone's doc (1055-1058).
  • lib/src/code_push.dart:410-414 vs 316 — still open (r4): the comment justifies gate 5 with "dispose() has already cancelled any running launch timer", but init() cancels only _timer, not _launchTimer. If an earlier chain reached _startLaunchTimer() (415) and the superseding init takes the store-install branch (326-350), the stale timer still reports launch success. Harmless in effect, but the comment claims more than the code delivers.
  • test/init_supersede_test.dart:53-57 — still open: handler(req) is unawaited, so a throw in the held-response handler surfaces as an unhandled async exception, not a test failure.
  • test/code_push_overlay_test.dart:272-273 — still open: the scan truncates at the first // including inside string literals, so the literal after a URL on one line is a false negative.

🟢 Positives

  • The round-5 fix landed at both missed sites, not one, each with a comment naming the "only a heard announcement consumes the token" rule so the three sites cannot drift apart.
  • The example fix is the right one — a flag set in the callback and checked in the !installed branch — so the reference implementation now follows the advice its own comment gives.
  • _installPendingRestart is still set before the epoch-gated callback (943/948, 1004/1008), so a stale chain records the install even though it must not announce it. That ordering has survived five rounds of churn intact.
  • The latch is cleared on both invalidating paths — rollback (1892-1895, and it is the first thing _rollbackInternal does, so no early return can skip it) and server-withdrawal convergence (931-932) — including the _pendingRestartAnnouncedEpoch = -1 reset. The ota_disabled kill switch (606-627) routes through _rollbackInternal and inherits the clearing for free.
  • Epoch threading through _startUpdateFlow stays disciplined — re-checked at continuation entry and after every await (394, 408, 430), each comment naming the side effect a stale chain would cause. I re-traced both platform branches: no unguarded await between 430 and the checkAndInstall at 445.
  • The re-arm's scoping (532) leaves the quiet-loser semantics untouched, and it provably cannot stampede: the first .then callback sets _checkInFlight/_inFlightEpoch/_checkDone synchronously at 546-548 before the second runs, so the second sees a live in-flight epoch and stays quiet.
  • test/init_supersede_test.dart:109-180 is still a genuine race gate — the liveLost listener plus expect(held, 1) proves the stale request was held when the live chain lost the guard, so the excluded path cannot produce a pass.
  • The crash-protection latch is memoized rather than short-circuited (2345-2346), preserving the load-bearing ordering at 402-406, and debugCrashProtectionRuns is bumped before the Platform.isIOS early return (2363-2364) so the guarantee is observable on a host runner (init_supersede_test.dart:251-287 pins the latch and its reset).
  • statusPatchActive is enforced by a scan over every lib/**.dart file with the wire value pinned separately (test/code_push_overlay_test.dart:253-285), so the constant cannot drift and existing users cannot break.

Structural exit from the one-token-many-listeners class: 'Restart to
apply' is now a named constant (statusRestartToApply), and the overlay
latches the restart-pending edge from CodePush.status - every listener
sees the status write, so the banner appears no matter which caller's
callback consumed the session announcement (the shipped example's
manual button being the case that lost it). The round-5 token rule is
pinned end-to-end through a REAL install tail (mocked engine persists
the payload): a callback-less install leaves the token, the next
callback-passing check receives it.
@claude

claude Bot commented Sep 2, 2026

Copy link
Copy Markdown

Round-7 review. Both round-6 Mediums are addressed and verified in the code: the overlay now latches the restart edge off CodePush.status (lib/src/code_push.dart:2840-2843) via the new CodePush.statusRestartToApply constant (178), so the one-token-per-session callback can no longer starve the banner; and test/init_supersede_test.dart:292-356 drives a real install tail (a mocked installPatch that actually writes patch.vmcode), exercising 1012-1019 and 713-718 for the first time. I re-traced dismiss semantics under the new latch and they hold — statusRestartToApply is written only at the two install sites, ValueNotifier suppresses same-value writes, and resume/periodic checks land on 'Patch already installed' (701) — so the round-3 Critical is not reintroduced.

🔴 Critical

None. Every new epoch check is a plain return false; _runCrashProtectionImpl keeps its whole body in try/catch (2365-2379) so the memoized future cannot reject and stall every later init; _checkDone cannot double-complete (1026-1028 clears, completes and nulls with no await between; the only other writer, 556, needs _checkInFlight == false); done.future never completes with an error, so the re-arm at 541-550 cannot raise an unhandled async error. setState from a notifier listener is safe because ChangeNotifier.notifyListeners reports listener errors rather than rethrowing into the status write. API surface stays additive; no platform channel or wire format touched.

🟠 Medium

1. The round-6 fix ships with no test — lib/src/code_push.dart:2834-2843; test/code_push_overlay_test.dart

grep -rn "statusRestartToApply" test/ returns nothing; the only 'Restart to apply' hits under test/ are the default banner's label (code_push_overlay_test.dart:88,165,171) and one assertion in hardening_test.dart:100. Nothing pumps an overlay and writes CodePush.status.value = CodePush.statusRestartToApply, so the latch can be deleted and the suite still passes. The property the fix leans on — a dismissed banner not being resurrected by the next check's status writes — is unpinned, while code_push_overlay_test.dart:98-121 pins the opposite direction ("comes back for the next update"). That contract now rests on ValueNotifier same-value suppression plus the already-installed branch writing 'Patch already installed'; both are one line from breaking silently. The install-tail test also never asserts status.value == statusRestartToApply, so the writer side is unpinned too. Fourth consecutive round this mechanism changed semantics (r3 sticky level → r4 unconditional stamp → r5 null-callback stamp → r6 status latch), each caught by reading rather than a gate. The seam makes it cheap: initState adds the listener at 2796 before the override early-return at 2801-2803, so three rows — raise, no-double-raise, not-resurrected-after-dismiss — close it.

2. The status latch is edge-only: an overlay that mounts after the install never sees it — lib/src/code_push.dart:2796-2797, 2840-2843, 637-640

initState adds the listener but never invokes _onModuleLoaded once to sample the current value, and addListener does not deliver it. The overlay sees only transitions after mount, while status is documented as "a fleeting TRANSITION, not a level you can poll" (191-193); "every listener sees the status write" is true only of listeners already attached.

Failure scenario: main() calls CodePush.init(...) with no onUpdateReady and the overlay mounts later than the install (splash route, deferred route, an await before runApp — round 2's mount-window framing). The install writes statusRestartToApply at 1013 with nobody attached; the token is correctly not consumed (1016 requires a callback), so the overlay's own initcheckAndInstall is the only remaining path — and it reaches the already-installed branch only if the server still offers the same patch, since data['patch_available'] != true returns at 637-640, well before 695. A server that stops offering a delivered patch gives the live session no second chance and the edge is already past: patch installed, no banner, rest of the session. Sampling the current values once in initState closes it (and the same pre-existing gap for _patchActive); the durable shape is a public level like moduleResult.

Merge gate: two open Mediums. Per policy this may merge only if each unfixed one is first filed as its own issue labelled deferred-medium. I have not filed them — that is the author's call.

🟡 Low

  • lib/src/code_push.dart:2643-2652, 2728-2730; README.md:348-354; CHANGELOG.md:22-24 — still open (r4–r6); the dartdoc copy matters most since pub.dev renders it. It states the race as current fact and prescribes "do NOT call CodePush.init in main()", while CHANGELOG.md:3-6 says it is fixed.
  • lib/src/code_push.dart:178; CHANGELOG.md:3-8; README.md:255-257statusRestartToApply is new public API in neither doc, though it is now the value apps compare against; the source-scan test (test/code_push_overlay_test.dart:253-285) is not extended to it, so it has no drift guard — the gap Make 'Patch active' a named constant (statusPatchActive) with a test #31 existed to close.
  • lib/src/code_push.dart:600, 821; README.md:255-257 — still open (r2–r6): 'Check superseded by a newer init' documented nowhere.
  • lib/src/code_push.dart:294-295 — still open (r5/r6): init's dartdoc was not updated for the false-return announcement that checkAndInstall's (486-494) now documents.
  • lib/src/code_push.dart:496-504; README.md:263-266 — still open: both still call the losing caller purely quiet; the re-arm (539-551) can write status after the winner returns.
  • lib/src/code_push.dart:2798-2800 — still open (r4–r6): _cycleOverride is captured after final cfg = _config, which throws at 2784; on that path dispose() calls the process-global CodePush.dispose() (2854) despite the seam, and test/code_push_overlay_test.dart:197-207 walks it.
  • lib/src/code_push.dart:1025-1028 vs 982 — still open: the finally runs before _iosLoadPayload resolves; documented on checkAndInstall (501-504) but not on _checkDone (1062-1067).
  • lib/src/code_push.dart:415-418 vs 324 — still open (r4/r6): init() cancels only _timer; a superseding init taking the store-install branch (334-358) never reaches _startLaunchTimer, so the earlier session's timer still reports launch success.
  • example/lib/main.dart:15-26, 72-110 — the example wraps in CodePushOverlay and drives a manual checkAndInstall; with the latch a manual install now also raises the overlay banner. Intended, but a user-visible change noted in neither the CHANGELOG nor the dartdoc.
  • test/init_supersede_test.dart:56-60 — still open: handler(req) unawaited, so a throw surfaces as an unhandled async exception.
  • test/code_push_overlay_test.dart:272-273 — still open: the scan truncates at the first //, including inside string literals.

🟢 Positives

  • Routing the restart signal through status resolves the token problem rather than patching over it: a level every listener observes replaces an edge one caller could consume, with a named constant so writers (952, 1013) and reader (2841) cannot drift.
  • init_supersede_test.dart:292-356 closes round-6 M2 the hard way — the mock actually persists patch.vmcode, so the follow-up check reaches the already-installed branch for the real reason, exercising the install tail and the re-announce together.
  • _installPendingRestart is still set before the epoch-gated callback (951/956, 1012/1016) — the ordering that makes re-delivery possible, intact after six rounds — and cleared on both invalidating paths: rollback (1900-1903, the first statement, so no early return skips it; the ota_disabled switch at 614-635 inherits it) and server-withdrawal convergence (939-940).
  • Epoch threading stays disciplined: re-checked at continuation entry and after every await (395, 416, 438), each comment naming the side effect a stale chain would cause. Both platform branches re-traced — no unguarded await between 438 and 453.
  • The re-arm provably cannot stampede: checkAndInstall sets _checkInFlight/_inFlightEpoch/_checkDone synchronously at 554-556 before its first await, which also makes round-2's "_inFlightEpoch never reset" unreachable.
  • The crash-protection latch is memoized rather than short-circuited (2350-2351), preserving the ordering at 405-414, with the run counter bumped before the Platform.isIOS return (2368-2369) so it is observable on a host runner (init_supersede_test.dart:254-290).
  • init_supersede_test.dart:112-183 remains a genuine race gate: liveLost plus expect(held, 1) proves the stale request was still held when the live chain lost the guard.

initState now evaluates the current notifier values once (exactly as
the listener would), so an overlay mounting AFTER an install or a
patch activation is not blind to a level the edge-only listeners will
never fire for. Both shapes pinned: the mid-life status edge shows the
banner, and a late-mounting overlay shows it too.
@claude

claude Bot commented Sep 2, 2026

Copy link
Copy Markdown

Round-8 review. Both round-7 Mediums landed and I verified them: the mount catch-up is real (lib/src/code_push.dart:2803 calls _onModuleLoaded() in initState, before final cfg = _config and before the CodePush.init whose first check would overwrite status), and the latch now has tests (test/code_push_overlay_test.dart:288-313). One new finding; round-7 Lows re-listed compactly rather than re-argued.

🔴 Critical

None. Every epoch check is a plain return false. _runCrashProtectionImpl keeps its whole body in try/catch, so the memoized future cannot reject and stall every later init. _checkDone cannot double-complete: 556 is the only other writer and is unreachable while _checkInFlight is true, and 1026-1028 clears/completes/nulls with no await between. The re-arm cannot stampede — checkAndInstall sets _checkInFlight/_inFlightEpoch/_checkDone synchronously at 554-556 before its first await. setState from initState (2803 then 2825) is legal: the element is already active and in scope of the current build target, so markNeedsBuild schedules a rebuild in the same buildScope pass rather than asserting. API surface is additive; no platform channel, wire format, or native file touched.

🟠 Medium

1. The overlay now has two independent deliveries of the same restart signal, and the second one undoes a dismiss — lib/src/code_push.dart:2846-2849 vs 713-719; 1012-1019, 951-959

Round 6 routed the restart-pending signal through status so every listener sees it. But the overlay also still passes _markUpdateReady as onUpdateReady (2818 init, 2881 resume), and the round-5 token rule at 1016/956 stamps the session token only when the installing caller passed a callback. So a callback-less install delivers through the status latch but leaves the token armed — and the next overlay-driven check spends it re-raising a banner the user already dismissed.

Concrete sequence, all on Android:

  1. Some caller installs with no callback — either CodePush.checkAndInstall(serverUrl:, appId:, releaseVersion:) standalone (onUpdateReady is optional, and README.md:161-166 now actively tells apps with several listeners to observe status instead of the callback), or the overlay chain installing on a superseded epoch, where entryEpoch != _initEpoch fails the guard at 1016. 1012 sets _installPendingRestart = true, 1013 writes statusRestartToApply, token unstamped.
  2. _onModuleLoaded (2846-2849) latches that status write, so the banner appears. Correct, and the point of round 6.
  3. User taps LATER, so _dismissBanner (2831-2833) sets _updateReady = false. status is untouched and still holds statusRestartToApply.
  4. Next resume or periodic check, both passing _markUpdateReady: the server still offers the same patch, so the already-installed branch at 695-720 runs. All four conditions hold (_installPendingRestart, onUpdateReady != null, entryEpoch == _initEpoch, _pendingRestartAnnouncedEpoch != _initEpoch), so _markUpdateReady() fires and the dismissed banner returns, for the same pending restart, with no new update behind it.

That contradicts the dismiss contract test/code_push_overlay_test.dart:114-135 pins (comes back for the next update). test/init_supersede_test.dart:292-356 is in fact a direct proof of the mechanism — it asserts precisely that a callback-less install leaves the token and that the next callback-passing check fires it; with the round-6 latch in place, that first raise has already happened by then.

Round 7 checked dismiss semantics and concluded they hold, but only over the status writers (same-value ValueNotifier suppression, later checks landing on Patch already installed at 701). The callback path at 713-719 was not in that trace — that is the new evidence here.

Cleanest fix: now that the overlay latches the level, stop having it consume the edge. Drop onUpdateReady: _markUpdateReady from 2818/2881 — the status latch covers install-time delivery and 2803 covers late mount — which also frees the session token for other listeners in the app instead of the overlay silently eating it. This is also the one row missing from the new test group: 288-313 pins raise-on-edge and raise-on-mount, but not not-resurrected-after-dismiss — exactly the row round 7 asked for, and exactly the row that would fail today.

Merge gate: one open Medium — under the two-Medium ceiling, so this does not block on count. It still wants a fix or a deferred-medium issue.

🟡 Low

  • lib/src/code_push.dart:2643-2652, 2665-2671, 2728-2730; README.md:348-354; CHANGELOG.md:22-24 — open since r4, the most user-visible loose end left. All five still state the CodePushOverlay does not actually supersede an earlier init(onUpdateReady:) — unguarded .then chain races #30 race as current fact and prescribe do NOT call CodePush.init in main(), while CHANGELOG.md:3-6 nineteen lines above says it is fixed. The class dartdoc renders on pub.dev.
  • lib/src/code_push.dart:178statusRestartToApply is new public API in neither CHANGELOG.md nor README.md:254-259 (which documents only statusPatchActive), and the drift-scan test (test/code_push_overlay_test.dart:260-278) scans only Patch active. It is now the value apps and the overlay compare against, so it has no drift guard — the gap Make 'Patch active' a named constant (statusPatchActive) with a test #31 existed to close.
  • lib/src/code_push.dart:600, 821; README.md:254-256 — open r2-r7: Check superseded by a newer init is a user-visible status value documented nowhere.
  • lib/src/code_push.dart:294-295 — open r5-r7: the checkAndInstall dartdoc (486-494) documents the false-return announcement; the init one still says only "When a patch is installed, onUpdateReady is called", and init is the more common entry point.
  • lib/src/code_push.dart:2804-2806 — open r4-r7, one-line fix: _cycleOverride is captured after final cfg = _config, which throws at 2784. On that path the capture stays null, so dispose() calls the process-global CodePush.dispose() (2860) despite the seam. test/code_push_overlay_test.dart:290-300 walks that path and bumps _initEpoch for every later test in the process. Read the static one line earlier.
  • lib/src/code_push.dart:496-504; README.md:263-266 — open: both still describe the losing caller as purely quiet; the re-arm (541-550) can write status after the winner returns.
  • lib/src/code_push.dart:1025-1028 vs 982 — open: return _iosLoadPayload(...) runs the finally before the load resolves, so a re-arm can start a check during the load. Documented on checkAndInstall (501-504) but not on _checkDone (1062-1067).
  • lib/src/code_push.dart:418-422 vs 324 — open r4/r6/r7: the comment justifies gate 5 with "dispose() has already cancelled any running launch timer", but init() cancels only _timer, not _launchTimer. A superseding init taking the store-install branch (334-358) never reaches _startLaunchTimer, so the earlier launch timer still reports launch success. Benign in effect; the comment claims more than the code delivers.
  • test/init_supersede_test.dart:59 — open: handler(req) is unawaited, so a throw in the held-response handler surfaces as an unhandled async exception rather than a test failure.
  • test/code_push_overlay_test.dart:272 — open: the scan truncates each line at the first //, including inside string literals.

🟢 Positives

  • The round-7 catch-up is placed correctly, which is the whole ballgame: _onModuleLoaded() at 2803 runs before final cfg = _config (2804) and before the CodePush.init at 2811. Latching _patchActive pre-first-build also means the KeyedSubtree key at 2892 is built with its final value, so a late-mounting overlay no longer re-keys and disposes the app subtree one frame in.
  • test/init_supersede_test.dart:292-356 closes round-6 M2 the hard way rather than with another debugSetInstallPendingRestart shortcut: the mocked installPatch actually persists patch.vmcode, and the getReleaseVersion mock returning null rather than throwing makes _probeEngineFingerprint fall back to unknown, so the baseline gate at 736-776 is cleared for the real reason. The follow-up check then reaches 695 genuinely.
  • test/init_supersede_test.dart:112-183 remains a real race gate — the liveLost listener plus expect(held, 1) proves the stale request was still held when the live chain lost the guard.
  • _installPendingRestart is still set before the epoch-gated callback (951/956, 1012/1016), and cleared on both invalidating paths: _rollbackInternal (1902-1903, its first two statements, so no early return can skip it; the ota_disabled kill switch at 614-635 inherits it for free) and server-withdrawal convergence (939-940).
  • Epoch threading through _startUpdateFlow stays disciplined: re-checked at continuation entry and after every await (402, 416, 438). I re-traced both platform branches — 447 is fire-and-forget and _quarantineFromBreadcrumb is synchronous, so there is no unguarded await between 438 and the checkAndInstall at 453, and none at all on iOS.
  • Every rollback path still writes status (1971, 2380, 624/632), so the new level catch-up cannot resurrect a banner for a patch that was just removed — the failure mode that reading a transient as a level would otherwise invite.
  • _cycleOverride being consulted by dispose/resume rather than the static is right: a test installing or clearing the seam mid-lifetime can no longer make the overlay tear down a live cycle it never started.

The status latch is now episode-scoped: while the restart-pending
level stands, a dismissal stands too - later notifier events
(moduleResult, status rewrites) cannot re-latch and undo it. The
episode resets when the status leaves the level, so a genuinely new
install (which transitions through other statuses) shows a fresh
banner. Pinned: dismissed-stays-dismissed under a second delivery, and
a new episode re-offers.
@claude

claude Bot commented Sep 2, 2026

Copy link
Copy Markdown

Round-9 review. The round-8 fix landed and I verified the half it covers: _restartLevelHandled (lib/src/code_push.dart:2775, 2856-2863) makes the status latch episode-scoped, and test/code_push_overlay_test.dart:289-317 pins both directions. The other delivery path in that finding — the onUpdateReady callback the overlay still hands to init/resume — was not touched, and it is the one that reproduces. Round-7/8 Lows are re-listed compactly, not re-argued.

🔴 Critical

None. Every epoch check is a plain return false. _runCrashProtectionImpl keeps its whole body in try/catch (lib/src/code_push.dart:2370-2388), so the memoized future (2353-2354) cannot reject and stall every later init. _checkDone cannot double-complete: 1025-1028 clears/completes/nulls with no await between, and the only other writer (556) is unreachable while _checkInFlight is true. The re-arm cannot stampede — checkAndInstall sets _checkInFlight/_inFlightEpoch/_checkDone synchronously at 554-556 before its first await — and done.future never completes with an error, so unawaited(...) at 541-550 raises nothing. setState from initState (2812 then 2834) is legal. Download hash verification (839-845) and the empty-hash identity fallback (850-852) are untouched. API surface is additive; no platform channel, wire format, or native file is touched.

🟠 Medium

1. The dismissed banner still returns — through the callback, not the notifier. lib/src/code_push.dart:2827/2895 vs 713-719; 2856-2863

The round-8 fix is scoped to _onModuleLoaded, and the commit message says so ("later notifier events ... cannot re-latch"). But the overlay has a second delivery of the same signal: it still passes onUpdateReady: _markUpdateReady to CodePush.init (2827) and to the resume checkAndInstall (2895), and _markUpdateReady (2833-2835) sets _updateReady = true directly, never passing through _restartLevelHandled.

Sequence, Android:

  1. A caller installs with no callback — CodePush.checkAndInstall(serverUrl:, appId:, releaseVersion:) standalone (README.md:141-159 documents it; 161-166 now recommends observing status instead of the callback), or the init-in-main() chain installing on a superseded epoch where entryEpoch != _initEpoch fails 1016. 1012 sets _installPendingRestart = true, 1013 writes statusRestartToApply, the token stays unstamped.
  2. _onModuleLoaded latches it: _restartLevelHandled = true, banner appears (2856-2860). Correct — the point of round 6.
  3. User taps LATER_dismissBanner (2840-2842) sets _updateReady = false. status still holds statusRestartToApply.
  4. Next resume or periodic check (462-470), both passing _markUpdateReady: 559 writes 'Checking server...', which fires the listener with a non-level value, so the else at 2861-2862 resets _restartLevelHandled = false. The check reaches the already-installed branch (695-721), writes 'Patch already installed' (701), and all four conditions at 713-716 hold — so _markUpdateReady() fires and the dismissed banner is back, for the same pending restart, with no new update behind it.

test/init_supersede_test.dart:292-356 proves the mechanism: it asserts that a callback-less install leaves the token and the next callback-passing check spends it. With the round-6 latch in place, the first raise has already happened by then. PR-introduced: on main the overlay's callback fired only at install time.

Fix, unchanged from round 8: drop onUpdateReady: _markUpdateReady from 2827/2895 (2856-2863 covers install-time delivery, 2812 covers late mount), which also frees the session token for other listeners. Or route _markUpdateReady through the same _restartLevelHandled gate.

The matching test row is still missing — 289-317 covers re-latch-via-notifier, not re-raise-via-callback. The seam exposes it: the captured signalUpdateReady is _markUpdateReady, so raise via status, tap LATER, set status to 'Patch already installed', call signalUpdateReady(), expect findsNothing. It fails today.

Merge gate: one open Medium — under the two-Medium ceiling, so it does not block on count. It still wants a fix or a deferred-medium issue.

🟡 Low

  • lib/src/code_push.dart:2643-2652, 2664-2671, 2728-2730; README.md:348-354; CHANGELOG.md:22-24 — open since r4, the most user-visible loose end. All five still state the CodePushOverlay does not actually supersede an earlier init(onUpdateReady:) — unguarded .then chain races #30 race as current fact and prescribe "do NOT call CodePush.init in main()", while CHANGELOG.md:3-6 says it is fixed. The class dartdoc renders on pub.dev.
  • lib/src/code_push.dart:178statusRestartToApply is new public API in neither CHANGELOG.md nor README.md:254-259, and the drift-scan test (test/code_push_overlay_test.dart:260-285) scans only 'Patch active'. It is now the value the overlay and apps compare against, so it has no drift guard — the gap Make 'Patch active' a named constant (statusPatchActive) with a test #31 exists to close.
  • lib/src/code_push.dart:600, 821; README.md:254-256 — open r2-r8: 'Check superseded by a newer init' documented nowhere.
  • lib/src/code_push.dart:294-295 — open r5-r8: init's dartdoc still says only "When a patch is installed, onUpdateReady is called"; checkAndInstall's (486-494) documents the false-return announcement and init is the more common entry point.
  • lib/src/code_push.dart:2813-2815 — open r4-r8, one-line fix: _cycleOverride is captured after final cfg = _config, which throws at 2793; on that path the capture stays null, so dispose() calls the process-global CodePush.dispose() (2874) despite the seam. test/code_push_overlay_test.dart:197-207 walks it.
  • lib/src/code_push.dart:496-504; README.md:263-266 — open: both call the losing caller purely quiet, but the re-arm (541-550) can write status after the winner returns.
  • lib/src/code_push.dart:1025-1028 vs 982 — open: the finally runs before _iosLoadPayload resolves, so a re-arm can start a check during the load. Documented on checkAndInstall (501-504) but not on _checkDone (1062-1067).
  • lib/src/code_push.dart:418-422 vs 324 — open r4/r6-r8: the comment claims dispose() cancelled any running launch timer, but init() cancels only _timer; a superseding init on the store-install branch (334-358) leaves the earlier launch timer to report success.
  • example/lib/main.dart:15-26, 72-117 — with the latch, the manual button's install now also raises the overlay banner alongside the example's own _status text, so the reference app prompts twice. Intended, but a user-visible change in neither the CHANGELOG nor the dartdoc.
  • test/code_push_overlay_test.dart:305, 316 — new: moduleResult is set non-null and reset only at the end of the test body; tearDown (34-40) resets debugUpdateCycleOverride, lastConfig, status but not moduleResult, so an earlier failing expect leaks it into every later test. Move the reset into tearDown/addTearDown.
  • test/init_supersede_test.dart:59 — open: handler(req) unawaited, so a throw surfaces as an unhandled async exception, not a failure.
  • test/code_push_overlay_test.dart:272-273 — open: the scan truncates each line at the first //, including inside string literals.

🟢 Positives

  • The round-8 fix is episode-scoped rather than sticky: the else reset (2861-2862) means a real second install — always transitioning through 'Checking server...' (559) and 'Installing (...)' (854) before rewriting the level at 1013 — starts a fresh episode and does raise a new banner, pinned at 310-315. The fix cannot be collapsed into a one-shot latch that swallows the second update.
  • The mount catch-up stays correctly placed: _onModuleLoaded() at 2812 runs before final cfg = _config (2813) and before CodePush.init (2820), so _patchActive is latched pre-first-build and the KeyedSubtree key at 2906 is built with its final value — a late-mounting overlay no longer re-keys and disposes the app subtree one frame in.
  • test/init_supersede_test.dart:292-356 closes round-6's gap the hard way: the mocked installPatch actually persists patch.vmcode (303-307) and the getPatchDir-only handler makes _probeEngineFingerprint fall back to unknown, so the baseline gate clears for the real reason and the follow-up check reaches 695 genuinely.
  • test/init_supersede_test.dart:112-183 remains a real race gate: liveLost plus expect(held, 1) (161) proves the stale request was still held when the live chain lost the guard.
  • _installPendingRestart is still set before the epoch-gated callback (951/956, 1012/1016), and cleared on both invalidating paths: _rollbackInternal (1902-1903, its first two statements, so no early return skips it; the ota_disabled switch at 614-635 inherits it) and server-withdrawal convergence (939-940).
  • Epoch threading through _startUpdateFlow stays disciplined — re-checked at continuation entry and after every await (402, 416, 438). Re-traced both branches: 447 is fire-and-forget and _quarantineFromBreadcrumb is synchronous, so no unguarded await between 438 and the checkAndInstall at 453, and none at all on iOS.
  • Every rollback and revert path still writes status (624/632, 941, 2380), so the level catch-up at 2812 cannot resurrect a banner for a patch that was just removed.
  • statusRestartToApply is used consistently: the literal appears in lib/ only at its declaration (178), with both writers (952, 1013) and the reader (2856) on the constant. Security-relevant paths read the same as on main — HTTPS-or-loopback gate (564-573), offer-hash verification (839-845), empty-hash fallback (850-852).

_markUpdateReady itself now enforces one-banner-per-restart-episode,
so the update-cycle callback (a resume check's re-announce) can no
longer undo a dismissal the notifier-side guard honored. A fresh
install transitions the status, resetting the episode, so new updates
still re-offer through either path. Pinned both ways.
@claude

claude Bot commented Sep 2, 2026

Copy link
Copy Markdown

Round-10 review. The round-9 guard reads a status value the real call site has already overwritten, so the round-9 finding still reproduces. Round-9 Lows are re-listed compactly, not re-argued.

🔴 Critical

None. Every epoch check is a plain return false. _runCrashProtectionImpl keeps its whole body in try/catch (lib/src/code_push.dart:2373-2387), so the memoized future (2353-2354) cannot reject and stall every later init. _checkDone cannot double-complete (1025-1028 clears/completes/nulls with no await between; the only other writer, 555, is unreachable while _checkInFlight is true) and never completes with an error, so the unawaited(...) re-arm at 541-550 raises nothing. The new _markUpdateReady guard is a pure early-return, and _onModuleLoaded() at 2812 runs with mounted == true, so setState from initState is legal. Security paths read identically to main: HTTPS-or-loopback gate, offer-hash verification (839-845), empty-hash identity fallback (846-852), download size cap. API surface is additive; no platform channel, wire format, or native file touched.

🟠 Medium

1. The dismissed banner still returns — the round-9 guard cannot fire at the site it was written for. lib/src/code_push.dart:2841-2843 vs 701/718

_markUpdateReady consults the episode state only while the status currently reads the restart level:

if (CodePush.status.value == CodePush.statusRestartToApply) {   // 2841
  if (_restartLevelHandled && !_updateReady) return;            // 2842
  _restartLevelHandled = true;
}
if (mounted) setState(() => _updateReady = true);               // 2845

The already-installed re-announce writes status.value = 'Patch already installed' at 701 before calling onUpdateReady() at 718, with no status write in between. So there the if at 2841 is false and 2845 sets _updateReady = true unconditionally. The 701 write also fires the listener synchronously first, and _onModuleLoaded's else resets _restartLevelHandled = false (2871) — as does 559 ('Checking server...') one step earlier. By the time the callback lands, both the guard's precondition and its state are gone.

Sequence, Android:

  1. A caller installs with no callback — checkAndInstall(serverUrl:, appId:, releaseVersion:) standalone (README.md:141-159; 161-166 now recommends observing status over the callback), or the init-in-main() chain installing on a superseded epoch where entryEpoch != _initEpoch fails 1016. 1012 sets _installPendingRestart = true, 1013 writes statusRestartToApply, token unstamped.
  2. _onModuleLoaded latches the level: banner appears, _restartLevelHandled = true.
  3. User taps LATER_dismissBanner (2851-2853) sets _updateReady = false; status still holds the level.
  4. Next resume (2904) or periodic check (462-470), both passing _markUpdateReady: 559 resets the episode; the server still offers the same patch, so 695-720 runs, 701 writes 'Patch already installed', all four conditions at 713-716 hold, and 718 calls _markUpdateReady() with the status off the level → the dismissed banner is back, same pending restart, no new update behind it.

PR-introduced: on main the overlay's callback fired only at the two install sites. It contradicts the dismiss contract test/code_push_overlay_test.dart:114-135 pins.

I could not find a reachable path where the 2841 guard does fire: the only sites calling onUpdateReady with the status still at the level are the two install tails (952/958, 1013/1018), and there the preceding status write already ran _onModuleLoaded, so _updateReady is true and !_updateReady fails. The guard is effectively dead code.

Why the new test misses it — test/code_push_overlay_test.dart:295-307: it sets the status to statusRestartToApply (295), taps LATER, then calls signalUpdateReady() (304) with the status left at the level — an ordering production never produces. Insert CodePush.status.value = 'Patch already installed'; await tester.pump(); before 304 and it fails.

Fix: let the episode survive the intermediate status writes instead of re-reading the level at callback time — record the dismissal for the current episode and end the episode only on a real level re-entry, not on any status leaving the level. Dropping onUpdateReady: _markUpdateReady from 2827/2904 (the round-8/9 suggestion) also closes it, since 2812 covers late mount and 2867-2869 covers install-time delivery.

Merge gate: one open Medium — under the two-Medium ceiling, so it does not block on count. It still wants a fix or a deferred-medium issue.

🟡 Low

All carried over, re-verified at current line numbers.

  • lib/src/code_push.dart:2643-2652, 2664-2671; README.md:348-354; CHANGELOG.md:22-24 — open since r4: all still state the CodePushOverlay does not actually supersede an earlier init(onUpdateReady:) — unguarded .then chain races #30 race as current fact and prescribe "do NOT call CodePush.init in main()", while CHANGELOG.md:3-6 says it is fixed. The class dartdoc renders on pub.dev.
  • lib/src/code_push.dart:178statusRestartToApply is new public API in neither CHANGELOG.md nor README.md:255-259, and the drift scan (test/code_push_overlay_test.dart:260-285) covers only 'Patch active' — the gap Make 'Patch active' a named constant (statusPatchActive) with a test #31 exists to close.
  • lib/src/code_push.dart:600, 821; README.md:254-256 — open r2-r9: 'Check superseded by a newer init' documented nowhere.
  • lib/src/code_push.dart:294-295 — open r5-r9: init's dartdoc was not updated for the false-return announcement that checkAndInstall's (486-494) documents.
  • lib/src/code_push.dart:2813-2815 — open r4-r9, one line: _cycleOverride is captured after final cfg = _config, which throws at 2793; on that path the capture stays null and dispose() calls the process-global CodePush.dispose() despite the seam.
  • lib/src/code_push.dart:496-504; README.md:263-266 — open: both still call the losing caller purely quiet; the re-arm (541-550) can write status after the winner returns.
  • lib/src/code_push.dart:1025-1028 vs 982 — open: the finally runs before _iosLoadPayload resolves, so a re-arm can start a check during the load.
  • lib/src/code_push.dart:418-422 vs 324 — open r4/r6-r9: init() cancels only _timer, so a superseding init on the store-install branch leaves the earlier launch timer to report success.
  • test/code_push_overlay_test.dart:334/345 — open r9: moduleResult is reset only at the end of the test body; tearDown (34-40) does not reset it, so a failing expect leaks it.
  • test/init_supersede_test.dart:59 — open: handler(req) unawaited, so a throw surfaces as an unhandled async exception.
  • test/code_push_overlay_test.dart:272-273 — open: the scan truncates each line at the first //, including inside string literals.

🟢 Positives

  • The round-9 fix targets the right seam: the episode rule belongs inside _markUpdateReady rather than duplicated per call site, and the else reset at 2871 keeps a genuinely new install from being swallowed (pinned at 309-315, 339-344).
  • The mount catch-up is placed correctly: _onModuleLoaded() at 2812 runs before final cfg = _config and before CodePush.init (2820), so _patchActive is latched pre-first-build and the KeyedSubtree key is built with its final value (362-373).
  • test/init_supersede_test.dart:292-356 closes the round-6 gap the hard way: the mocked installPatch actually persists patch.vmcode (303-307), so the follow-up check reaches 695 for the real reason.
  • test/init_supersede_test.dart:112-183 is still a real race gate: liveLost plus expect(held, 1) proves the stale request was held when the live chain lost the guard.
  • _installPendingRestart is set before the epoch-gated callback (951/956, 1012/1016) and cleared on both invalidating paths: _rollbackInternal (1902-1903, its first two statements) and server-withdrawal convergence (939-940).
  • Epoch threading stays disciplined: re-checked at continuation entry and after every await (402, 416, 438). Both branches re-traced — no unguarded await between 438 and the checkAndInstall at 453.
  • The re-arm cannot stampede: checkAndInstall assigns _checkInFlight/_inFlightEpoch/_checkDone synchronously at 553-555 before its first await, so a same-epoch loser stays quiet and test/hardening_test.dart's semantics are untouched.
  • The crash-protection latch is memoized rather than short-circuited (2353-2354), with debugCrashProtectionRuns bumped before the Platform.isIOS return (2371-2372) so the once-per-launch guarantee is observable on a host runner (test/init_supersede_test.dart:254-290).
  • statusPatchActive/statusRestartToApply are used consistently — the 'Patch active' literal appears in lib/ only at its declaration, enforced by a scan over every lib/**.dart file with the wire value pinned separately.

… status strings

The round-9 guard was dead code - the re-announce site overwrites the
status before the callback fires, so a status-keyed episode rule can
never see the level at the moment it matters. Episodes are now the
SDK's own truth: isRestartPending + a monotonic installSeq bumped at
each install commit. The overlay records which episode was dismissed;
that dismissal stands across BOTH delivery paths and any status churn,
and only a LATER install (a new episode) re-offers. The status-string
latch is gone; every notifier event re-evaluates the pending level, so
a late-mounting overlay shows the banner even while the status reads a
transient - a gap the old latch had. Tests now drive the production
ordering the round-10 review specified (status overwritten before the
callback), plus the transient-status mount case.
@fonkamloic

Copy link
Copy Markdown
Contributor Author

Round-10 response: taken at the root rather than another patch on the guard — you were right that the status channel cannot anchor episodes (busy channel; the guard was dead code). Episodes are now the SDK's own truth: isRestartPending + a monotonic installSeq bumped at each commit. The overlay records the dismissed episode; the dismissal stands across both delivery paths and any status churn (your exact production ordering — status overwritten, then callback — is now the test), and only a later install re-offers. Bonus closed: a late-mounting overlay now banners even while the status reads a transient, which the string latch silently missed. 177/177.

@claude

claude Bot commented Sep 3, 2026

Copy link
Copy Markdown

Round-11 review. The round-10 Medium is fixed — I traced the mechanism rather than the description, and it no longer reproduces on the production ordering. Rounds 7–10 Lows are re-listed compactly, not re-argued; three are new.

🔴 Critical

None.

  • No native, platform-channel, or wire change. The diff touches CHANGELOG.md, README.md, example/lib/main.dart, lib/src/code_push.dart and two test files only — android/ and ios/ are untouched, and no invokeMethod name, /api/v1/updates query parameter, or on-disk file name changed. Both new constants keep their existing values (lib/src/code_push.dart:170, 178).
  • Public API is additive: statusPatchActive, statusRestartToApply, isRestartPending (1059), installSeq (1068), plus @visibleForTesting hooks. init and checkAndInstall signatures are unchanged. The one behavioural change to an existing contract — onUpdateReady can fire on a false return — only fires where the old code fired nothing, and is documented at 486-494; see the Lows for where that documentation stops short.
  • The update path stays failure-soft. _runCrashProtectionImpl keeps its whole body in try/catch after the platform check (2394-2412), so the newly memoized future (2377-2378) can never reject and stall every later init. Every epoch check is a plain return false. The new _markUpdateReady guard (2862-2865) is a pure early return. _checkDone cannot double-complete — 1028-1030 clears/completes/nulls with no await between, and the only other writer (556) is unreachable while _checkInFlight is true — and never completes with an error, so the unawaited(...) re-arm at 541-550 raises nothing. setState from initState (28352866/2884) is legal: the element is already active and in the current build target's scope.
  • Security paths read identically to main: HTTPS-or-loopback gates for both the offer (564-573) and the patch URL (651-660), offer-hash verification before any byte is used (839-845), the empty-hash identity fallback (846-852), and the download size cap.

🟠 Medium

None.

The round-10 finding does not reproduce. I re-traced its exact sequence on Android against the new anchor:

  1. A callback-less caller installs. _installSeq++ (1014) runs before the status write at 1015, so both delivery paths see the bumped sequence: the synchronous listener → _onModuleLoaded (2894) → _markUpdateReady raises the banner for episode N.
  2. LATER_dismissBanner (2875-2877) records _dismissedInstallSeq = N off CodePush.installSeq, not off a status string.
  3. Next resume/periodic check: 559 writes Checking server... and 701 writes Patch already installed — both fire _onModuleLoaded, both reach _markUpdateReady, both hit installSeq == _dismissedInstallSeq and return. The re-announce callback at 718 lands on the same guard. The status churn that made the round-9/10 guards dead code no longer participates.
  4. A genuinely later install bumps to N+1 and re-offers, so the fix is not a one-shot latch.

test/code_push_overlay_test.dart:340-348 is now the round-10 ordering itself (status overwritten to Patch already installed first, then signalUpdateReady()), and 327-361 walks dismiss → notifier churn → later install in one case. That is the row rounds 8–10 kept asking for.

🟡 Low

  • lib/src/code_push.dart:2667-2676, 2688-2695; README.md:348-354; CHANGELOG.md:22-24 — open since r4, and the most user-visible loose end left. All four still state the CodePushOverlay does not actually supersede an earlier init(onUpdateReady:) — unguarded .then chain races #30 race as current fact and prescribe "do NOT call CodePush.init in main()", while CHANGELOG.md:3-6 — nineteen lines above 22-24, in the same ## Unreleased section — says it is fixed. The class dartdoc renders on pub.dev.
  • CHANGELOG.md:6new. "a check that lost to an in-flight one retries automatically" is unconditional, but 540 re-arms only when the in-flight check belongs to a superseded epoch; a same-epoch loser keeps the quiet-loser semantics. Relatedly, checkAndInstall's dartdoc (496-504) describes the losing caller as purely quiet and never mentions that the re-arm can re-invoke that caller's onUpdateReady after its future already resolved false (541-550). That is the part of the contract a caller needs: the example's own updateAnnounced pattern (example/lib/main.dart:80-111) reads the flag at 107 before any re-armed check could set it, so its false-branch message stands while a retry is still queued.
  • lib/src/code_push.dart:178, 1059, 1068statusRestartToApply, isRestartPending and installSeq are new public API in neither CHANGELOG.md nor README.md, and the drift-scan test (test/code_push_overlay_test.dart:260-285) still scans only 'Patch active'. statusRestartToApply is now the value the overlay and apps compare against, so it carries exactly the drift exposure Make 'Patch active' a named constant (statusPatchActive) with a test #31 exists to close.
  • README.md:165-166new. It tells widgets with several listeners to "observe CodePush.status / CodePush.moduleResult instead", which is the pattern rounds 8–10 established cannot anchor the restart-pending level (a listener that mounts after the install never sees the edge). The SDK now has the right answer — isRestartPending, whose own dartdoc at 1056-1058 says "must read it here, not by string-comparing status" — but the README never names it.
  • lib/src/code_push.dart:2894 vs 1926, 939new. The overlay now raises the banner from the level, but nothing lowers it when the level clears: _updateReady is written true at 2866 and false only in _dismissBanner (2878). After _rollbackInternal (1926) or the withdrawal-convergence branch (939) clears _installPendingRestart, an already-raised banner keeps offering a restart for a patch that is gone. Pre-existing in symptom — main never lowered it either — but the level makes the fix one line in _onModuleLoaded.
  • lib/src/code_push.dart:600, 821; README.md:254-256 — open r2-r10: Check superseded by a newer init is a user-visible status value documented nowhere.
  • lib/src/code_push.dart:294-295 — open r5-r10: init's dartdoc still says only "When a patch is installed, onUpdateReady is called"; checkAndInstall's (486-494) documents the false-return announcement, and init is the more common entry point.
  • lib/src/code_push.dart:2836-2838 — open r4-r10, one line: _cycleOverride is captured after final cfg = _config, which throws at 2816. On that path the capture stays null, so dispose() calls the process-global CodePush.dispose() (2907) despite the seam; test/code_push_overlay_test.dart:197-207 walks it. Read the static one line earlier.
  • lib/src/code_push.dart:1027-1030 vs 983 — open: the finally runs before _iosLoadPayload resolves, so a re-arm can start a check during the load. Documented on checkAndInstall (501-504) but not on _checkDone (1086-1091).
  • lib/src/code_push.dart:418-422 vs 324 — open r4/r6-r10: the comment justifies gate 5 with "dispose() has already cancelled any running launch timer", but init() cancels only _timer. A superseding init taking the store-install branch (334-358) never reaches _startLaunchTimer (2415), so the earlier launch timer still reports success. Benign in effect; the comment claims more than the code delivers.
  • lib/src/code_push.dart:2798new, minor. _dismissedInstallSeq is per-State while installSeq/isRestartPending are process-global, so a remounted overlay re-offers an episode the user already dismissed. Probably intended — it matches the "a genuinely new session gets exactly one" rule at 1049-1050 — but it is not stated on _dismissedInstallSeq and nothing pins it.
  • test/code_push_overlay_test.dart:351-354 — open r9/r10: moduleResult is set non-null and reset only at the end of the test body; tearDown (35-41) resets debugUpdateCycleOverride, lastConfig and status but not moduleResult, so a failing expect at 353 leaks it into every later test. Move it to addTearDown.
  • test/init_supersede_test.dart:59 — open: handler(req) is unawaited, so a throw in the held-response handler surfaces as an unhandled async exception rather than a test failure.
  • test/code_push_overlay_test.dart:272-273 — open: the scan truncates each line at the first //, including inside string literals.

🟢 Positives

  • Anchoring episodes on the SDK's own install sequence is the right root fix, and the ordering is what makes it work: _installSeq++ precedes the status write at both install tails (952/953 on iOS, 1014/1015 on Android), so the notifier path and the callback path observe the same episode number. The guard then lives in one place (2862-2865) instead of being duplicated per call site — which is why it survives the status churn that killed the round-9 and round-10 attempts.
  • It is not a one-shot latch. Because the anchor is a monotonic sequence rather than a boolean, a real second install re-offers (test/code_push_overlay_test.dart:356-360) while a re-announce of the same pending install does not — both directions pinned in one test.
  • test/code_push_overlay_test.dart:312-325 is the case the previous string latch silently missed: an overlay mounting after the install, with the status reading No update (204), still banners. That is the level doing work the edge could not.
  • Epoch threading through _startUpdateFlow stays disciplined — re-checked at continuation entry and after every await (402, 416, 438). I re-traced both branches: 447 is fire-and-forget and _quarantineFromBreadcrumb is synchronous, so there is no unguarded await between 438 and the checkAndInstall at 453, and none at all on iOS.
  • The re-arm provably cannot stampede: checkAndInstall assigns _checkInFlight/_inFlightEpoch/_checkDone synchronously at 554-556 before its first await, so a second re-armed loser sees a live in-flight check and stays quiet. test/init_supersede_test.dart:112-183 is a genuine race gate rather than a timing coincidence — the liveLost listener plus expect(held, 1) at 161 proves the stale request was still held when the live chain lost the guard.
  • _installPendingRestart is set before the epoch-gated callback (951/957, 1013/1018) and cleared on both invalidating paths: _rollbackInternal (1926-1927, its first two statements, so no early return can skip it — the ota_disabled kill switch at 614-635 inherits it for free) and server-withdrawal convergence (939-940).
  • test/init_supersede_test.dart:292-356 closes the round-6 gap the hard way rather than with another debug shortcut: the mocked installPatch actually persists patch.vmcode (303-307), and the getPatchDir-only handler makes _probeEngineFingerprint fall back to unknown, so the baseline gate clears for the real reason and the follow-up check reaches 695 genuinely.
  • The crash-protection latch is memoized rather than short-circuited (2377-2378), preserving the ordering at 410-414, with debugCrashProtectionRuns bumped before the Platform.isIOS return (2395-2396) so the once-per-launch guarantee is observable on a host runner (test/init_supersede_test.dart:254-290).
  • _cycleOverride being consulted by dispose/resume rather than by the static is right: a test installing or clearing the seam mid-lifetime can no longer make the overlay tear down a live cycle it never started.

@fonkamloic
fonkamloic merged commit 7ade675 into main Sep 3, 2026
1 check passed
@fonkamloic
fonkamloic deleted the fix/overlay-hardening branch September 3, 2026 02:21
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

1 participant