Skip to content

Commit 702d3eb

Browse files
fryanpanclaude
andcommitted
ADFA-4128: qb 04 review fixes — reload failure attribution + surfacing
- Stale pendingReloadGeneration mis-blaming later crashes: the backgrounded apply now assigns the pending slot too (Generations.pendingAfterApply), and BootProbation.generationToBlame refuses a pending value the store has moved past. Covered by BootProbationTest.aPendingReloadTheStoreMovedPastIsNotBlamed (fails without the fix) and GenerationsTest.aBackgroundedApplyClearsThePendingSlotItAlreadyAcked. - failReload swallowing every pre-apply failure: the newer-generation guard is now a three-way Generations.onReloadFailure — never-applied failures skip the rollback/quarantine but still reportCrash + banner; only a failure superseded by a newer live generation stays silent. Covered by GenerationsTest.aFailureTheStoreNeverAdoptedStillReports. - Binder-thread setProviders + immediate provider close racing main-thread inflation: ResourceStore now performs the field swap, setProviders and the close of the replaced provider on the main thread (inline when already there, so the boot restore path still lands before first inflation; Looper FIFO keeps a posted swap ahead of the posted recreate). Pure threading with no JVM seam — justified in swapProvidersOnMain's doc; device-covered. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Kj9YeCDHGp9DU8LPtfWJ7W
1 parent 8b98fc5 commit 702d3eb

6 files changed

Lines changed: 191 additions & 31 deletions

File tree

‎quickbuild/runtime/src/main/java/com/itsaky/androidide/quickbuild/runtime/BootProbation.java‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -28,13 +28,13 @@ synchronized void bootedFromStore(long generation) {
2828
* The generation a crash happening right now should be quarantined against.
2929
*
3030
* @param pendingReloadGeneration
31-
* the hot-swapped generation awaiting its first frame, or -1; it outranks the booted one, being the newer claim on the screen that just died
31+
* the hot-swapped generation awaiting its first frame, or -1; it outranks the booted one, being the newer claim on the screen that just died - unless the store has already moved past it, which means the value is stale (a later deploy acked while backgrounded) and the crash belongs to whatever runs now, not to it
3232
* @param liveGeneration
3333
* the generation the store currently serves, which is how a booted generation superseded by a later deploy stops being blamed for that deploy's crash
3434
* @return the generation to quarantine, or -1 when nothing this process adopted is to blame
3535
*/
3636
synchronized long generationToBlame(long pendingReloadGeneration, long liveGeneration) {
37-
if (pendingReloadGeneration >= 0) {
37+
if (pendingReloadGeneration >= 0 && pendingReloadGeneration >= liveGeneration) {
3838
return pendingReloadGeneration;
3939
}
4040
if (unprovenGeneration >= 0 && unprovenGeneration == liveGeneration) {

‎quickbuild/runtime/src/main/java/com/itsaky/androidide/quickbuild/runtime/Generations.java‎

Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,41 @@ static boolean accepts(long runningGeneration, long incomingGeneration) {
2020
return incomingGeneration > runningGeneration;
2121
}
2222

23+
/**
24+
* What a failed reload owes, decided from where the store stands relative to the failure.
25+
*
26+
* The three cases matter because {@link #rollbackApplies} alone conflates two of them: a failure superseded by a newer deploy must stay silent, but a failure the store never adopted - an oversize payload, a full disk, a restart deploy missing its dex - still has to reach the host and the banner, or its only trace is the host's deploy timeout.
27+
*
28+
* @param runningGeneration
29+
* generation the store holds right now
30+
* @param failedGeneration
31+
* generation whose reload failed
32+
* @return the action the failure path must take
33+
*/
34+
static FailureAction onReloadFailure(long runningGeneration, long failedGeneration) {
35+
if (rollbackApplies(runningGeneration, failedGeneration)) {
36+
return FailureAction.ROLLBACK_AND_REPORT;
37+
}
38+
return runningGeneration > failedGeneration
39+
? FailureAction.LEAVE_ALONE
40+
: FailureAction.REPORT_ONLY;
41+
}
42+
43+
/**
44+
* The pending-reload generation the runtime should hold after a payload applies.
45+
*
46+
* A foreground apply leaves the generation pending until its first resumed frame acks it. A backgrounded apply acks at apply time, and the pending slot must still be assigned - not skipped: leaving an older generation's pending value behind is what let the crash guard blame it for a later generation's crash, and let the crashing generation escape quarantine.
47+
*
48+
* @param resumedActivity
49+
* whether an activity is resumed, i.e. whether there is a frame to prove the reload on
50+
* @param generation
51+
* the generation just applied
52+
* @return the value the pending slot must take: the generation while its ack waits for a frame, or -1 when the apply was already acked
53+
*/
54+
static long pendingAfterApply(boolean resumedActivity, long generation) {
55+
return resumedActivity ? generation : -1;
56+
}
57+
2358
/**
2459
* Decides whether a failed reload's rollback still applies.
2560
*
@@ -36,4 +71,14 @@ static boolean rollbackApplies(long runningGeneration, long failedGeneration) {
3671
}
3772

3873
private Generations() {}
74+
75+
/** What {@link #onReloadFailure} tells the failure path to do. */
76+
enum FailureAction {
77+
/** The store still holds the failed generation: roll back, quarantine, report, banner. */
78+
ROLLBACK_AND_REPORT,
79+
/** The store never adopted the failed generation: nothing to roll back or quarantine, but report and banner still fire. */
80+
REPORT_ONLY,
81+
/** A newer generation owns the store, the pending ack and the screen: touch nothing, say nothing. */
82+
LEAVE_ALONE
83+
}
3984
}

‎quickbuild/runtime/src/main/java/com/itsaky/androidide/quickbuild/runtime/QuickBuildRuntime.java‎

Lines changed: 19 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,7 @@
1717
*
1818
* Installed once per process by {@link QuickBuildAppComponentFactory} at application instantiation; Context work - binding to CoGo, cache dirs - waits for the first activity, since the Application has no base context yet.
1919
*
20-
* Failure policy throughout: a reload failure reports the crash and rolls back, so the app keeps running the last working code rather than crash-looping or silently claiming the new generation.
20+
* Failure policy throughout: a reload failure reports the crash, and rolls back when the store adopted the failed generation, so the app keeps running the last working code rather than crash-looping or silently claiming the new generation. Only a failure superseded by a newer live generation stays silent.
2121
*/
2222
final class QuickBuildRuntime {
2323

@@ -273,10 +273,13 @@ void handlePayload(long generation, ParcelFileDescriptor dexPayload,
273273
PayloadStore.INSTANCE.baselineFingerprint(),
274274
application.getCacheDir());
275275
}
276-
if (tracker.hasResumedActivity()) {
277-
pendingReloadStartUptime = startUptime;
278-
pendingReloadGeneration = generation;
279-
} else {
276+
boolean resumed = tracker.hasResumedActivity();
277+
pendingReloadStartUptime = startUptime;
278+
// Assigned on BOTH branches: the backgrounded ack must also clear any older
279+
// generation still pending, or the crash guard keeps blaming it for this
280+
// generation's crashes - and this generation escapes quarantine.
281+
pendingReloadGeneration = Generations.pendingAfterApply(resumed, generation);
282+
if (!resumed) {
280283
// Backgrounded: no resumed activity to hang a frame callback on, so
281284
// waiting for render-proof would time out a deploy that worked. Ack at
282285
// apply+persist, like the restart path.
@@ -487,7 +490,9 @@ private void exitForRestart() {
487490
}
488491

489492
/**
490-
* Rolls back to {@code rollback}, reports the crash to CoGo, and shows the banner; the app stays on the old generation.
493+
* Reports the failure to CoGo and shows the banner; rolls back only when the store adopted the failed generation, so the app stays on the old one either way.
494+
*
495+
* A failure before the apply took - an oversize payload, a persist failure, a restart deploy missing its dex - leaves the store on the previous generation, so there is nothing to restore or quarantine; the report and banner still fire, or the host's only signal would be its deploy timeout. Only a failure superseded by a newer live generation stays silent, since that generation owns the store, the pending ack and the screen.
491496
*
492497
* @param generation
493498
* the generation that failed, which CoGo marks bad
@@ -497,16 +502,20 @@ private void exitForRestart() {
497502
* the failure, summarized into both the report and the banner
498503
*/
499504
private void failReload(long generation, PayloadStore.Payload rollback, Throwable error) {
500-
if (!Generations.rollbackApplies(PayloadStore.INSTANCE.generation(), generation)) {
505+
Generations.FailureAction action = Generations.onReloadFailure(
506+
PayloadStore.INSTANCE.generation(), generation);
507+
if (action == Generations.FailureAction.LEAVE_ALONE) {
501508
// A newer payload landed while this one was failing, so it owns the store, the
502509
// pending ack and the screen. Rolling back here would undo a deploy that worked.
503510
RuntimeLog.w("gen " + generation + " failed but gen "
504511
+ PayloadStore.INSTANCE.generation() + " is live; leaving it alone", error);
505512
return;
506513
}
507-
PayloadStore.INSTANCE.restore(rollback);
508-
quarantine(generation);
509-
pendingReloadGeneration = -1;
514+
if (action == Generations.FailureAction.ROLLBACK_AND_REPORT) {
515+
PayloadStore.INSTANCE.restore(rollback);
516+
quarantine(generation);
517+
pendingReloadGeneration = -1;
518+
}
510519
String summary = summarize(error);
511520
setOverlayState(OverlayState.crashed(summary));
512521
client.reportCrash(generation, summary);

‎quickbuild/runtime/src/main/java/com/itsaky/androidide/quickbuild/runtime/ResourceStore.java‎

Lines changed: 63 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,8 @@
66
import android.content.res.loader.ResourcesLoader;
77
import android.content.res.loader.ResourcesProvider;
88
import android.os.Build;
9+
import android.os.Handler;
10+
import android.os.Looper;
911
import android.os.ParcelFileDescriptor;
1012
import java.io.File;
1113
import java.io.IOException;
@@ -209,13 +211,19 @@ private void applyTableLegacy(ParcelFileDescriptor tableFd, long generation,
209211
@TargetApi(30)
210212
private void applyTableWithLoader(ParcelFileDescriptor tableFd) throws IOException {
211213
try {
212-
ResourcesProvider next = ResourcesProvider.loadFromApk(tableFd, null);
213-
synchronized (this) {
214-
ResourcesProvider previous = provider;
215-
provider = next;
216-
installProviders();
217-
Streams.closeQuietly(previous);
218-
}
214+
final ResourcesProvider next = ResourcesProvider.loadFromApk(tableFd, null);
215+
swapProvidersOnMain(new Runnable() {
216+
217+
@Override
218+
public void run() {
219+
synchronized (ResourceStore.this) {
220+
ResourcesProvider previous = provider;
221+
provider = next;
222+
installProviders();
223+
Streams.closeQuietly(previous);
224+
}
225+
}
226+
});
219227
} finally {
220228
// loadFromApk dups the fd internally; ours must be closed either way.
221229
Streams.closeQuietly(tableFd);
@@ -275,16 +283,54 @@ private void installProviders() {
275283
*/
276284
@TargetApi(30)
277285
private void refreshAssetsProvider(File dir) throws IOException {
278-
DirectoryAssetsProvider nextDir = new DirectoryAssetsProvider(dir);
279-
ResourcesProvider next = ResourcesProvider.empty(nextDir);
280-
synchronized (this) {
281-
ResourcesProvider previous = assetsProvider;
282-
DirectoryAssetsProvider previousDir = assetsDirProvider;
283-
assetsProvider = next;
284-
assetsDirProvider = nextDir;
285-
installProviders();
286-
Streams.closeQuietly(previous);
287-
Streams.closeQuietly(previousDir);
286+
final DirectoryAssetsProvider nextDir = new DirectoryAssetsProvider(dir);
287+
final ResourcesProvider next = ResourcesProvider.empty(nextDir);
288+
swapProvidersOnMain(new Runnable() {
289+
290+
@Override
291+
public void run() {
292+
synchronized (ResourceStore.this) {
293+
ResourcesProvider previous = assetsProvider;
294+
DirectoryAssetsProvider previousDir = assetsDirProvider;
295+
assetsProvider = next;
296+
assetsDirProvider = nextDir;
297+
installProviders();
298+
Streams.closeQuietly(previous);
299+
Streams.closeQuietly(previousDir);
300+
}
301+
}
302+
});
303+
}
304+
305+
/**
306+
* Runs a provider swap on the main thread, inline when already there.
307+
*
308+
* The swap must not run on the binder thread the deploy arrives on: setProviders rebuilds every attached Resources in place and the swap then closes the replaced provider's ApkAssets, either of which can race an inflation already in progress on the main thread - a lookup straddling the swap mixes old and new values, or touches a just-closed provider. Serializing with the main thread removes both races, and Looper FIFO keeps a posted swap ahead of the recreate the deploy posts right after it.
309+
*
310+
* Inline on the main thread, not posted, because the boot restore path runs during the first activity's creation and its swap must land before anything inflates.
311+
*
312+
* A swap failure is logged rather than thrown: on the posted path no caller is left to catch it, and the previous provider set stays live either way, which the next deploy replaces.
313+
*
314+
* @param swap
315+
* the field swap + setProviders + close of the replaced provider, taking the store's monitor itself
316+
*/
317+
private void swapProvidersOnMain(final Runnable swap) {
318+
Runnable guarded = new Runnable() {
319+
320+
@Override
321+
public void run() {
322+
try {
323+
swap.run();
324+
} catch (Throwable error) {
325+
RuntimeLog.e("resource provider swap failed; previous set stays live", error);
326+
}
327+
}
328+
};
329+
Looper main = Looper.getMainLooper();
330+
if (Looper.myLooper() == main) {
331+
guarded.run();
332+
} else {
333+
new Handler(main).post(guarded);
288334
}
289335
}
290336
}

‎quickbuild/runtime/src/test/java/com/itsaky/androidide/quickbuild/runtime/BootProbationTest.java‎

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -70,6 +70,15 @@ void anUnstampedBaselineIsNotAGenerationWorthRefusing() {
7070
assertThat(probation.generationToBlame(NO_PENDING_RELOAD, 0)).isEqualTo(NO_PENDING_RELOAD);
7171
}
7272

73+
@Test
74+
void aPendingReloadAheadOfTheStoreIsStillBlamed() {
75+
// The failure path's window: gen 11 failed, the store was just restored to 10, and the
76+
// pending slot has not been cleared yet. A crash here is still gen 11's doing.
77+
BootProbation probation = new BootProbation();
78+
79+
assertThat(probation.generationToBlame(11, 10)).isEqualTo(11);
80+
}
81+
7382
@Test
7483
void aPendingReloadOutranksTheGenerationThisProcessBooted() {
7584
// Both are live claims on the screen; the hot swap is the newer one, and it is the one
@@ -80,6 +89,17 @@ void aPendingReloadOutranksTheGenerationThisProcessBooted() {
8089
assertThat(probation.generationToBlame(11, 11)).isEqualTo(11);
8190
}
8291

92+
@Test
93+
void aPendingReloadTheStoreMovedPastIsNotBlamed() {
94+
// Deploy 9 lands foreground and is left pending its first frame; the user backgrounds,
95+
// deploy 10 applies and acks in the background. A crash now is gen 10's: blaming stale
96+
// 9 would quarantine working code, mislead CoGo, AND let 10 boot again on relaunch -
97+
// the exact startup crash-loop the quarantine machinery exists to break.
98+
BootProbation probation = new BootProbation();
99+
100+
assertThat(probation.generationToBlame(9, 10)).isEqualTo(NO_PENDING_RELOAD);
101+
}
102+
83103
@Test
84104
void aProcessThatBootedTheInstalledCodeBlamesNothing() {
85105
// The baked baseline is the floor a quarantine falls back to. Refusing it would leave

‎quickbuild/runtime/src/test/java/com/itsaky/androidide/quickbuild/runtime/GenerationsTest.java‎

Lines changed: 42 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -6,15 +6,55 @@
66

77
class GenerationsTest {
88

9+
@Test
10+
void aBackgroundedApplyClearsThePendingSlotItAlreadyAcked() {
11+
// The regression: deploy 9 lands foreground and is left pending, the user backgrounds
12+
// before its first resumed frame, deploy 10 applies and acks in the background. The
13+
// backgrounded apply must still assign the slot - skipping it left stale 9 behind, so
14+
// the crash guard blamed 9 for gen 10's crashes and 10 escaped quarantine.
15+
assertThat(Generations.pendingAfterApply(false, 10)).isEqualTo(-1);
16+
}
17+
18+
// The stamped-baseline boot gate is pinned in PersistedSelectionTest, against the real
19+
// PayloadStore seam - re-numbering accepts() cases here could not fail on that caller.
20+
921
@Test
1022
void acceptsStrictlyNewerGeneration() {
1123
assertThat(Generations.accepts(0, 1)).isTrue();
1224
assertThat(Generations.accepts(41, 42)).isTrue();
1325
assertThat(Generations.accepts(41, 100)).isTrue();
1426
}
1527

16-
// The stamped-baseline boot gate is pinned in PersistedSelectionTest, against the real
17-
// PayloadStore seam - re-numbering accepts() cases here could not fail on that caller.
28+
@Test
29+
void aFailureSupersededByANewerLiveGenerationStaysSilent() {
30+
// Gen 6's posted recreate throws after gen 7 already applied: gen 7 owns the store,
31+
// the pending ack and the screen, so gen 6's failure must touch and say nothing.
32+
assertThat(Generations.onReloadFailure(7, 6))
33+
.isEqualTo(Generations.FailureAction.LEAVE_ALONE);
34+
}
35+
36+
@Test
37+
void aFailureTheStoreNeverAdoptedStillReports() {
38+
// An oversize payload, a persist failure, a restart deploy missing its dex: the store
39+
// still runs the previous generation, so there is nothing to roll back or quarantine -
40+
// but the report and banner must fire, or the failure's only trace is the host's
41+
// deploy timeout and the developer sees nothing on device.
42+
assertThat(Generations.onReloadFailure(5, 6))
43+
.isEqualTo(Generations.FailureAction.REPORT_ONLY);
44+
assertThat(Generations.onReloadFailure(0, 1))
45+
.isEqualTo(Generations.FailureAction.REPORT_ONLY);
46+
}
47+
48+
@Test
49+
void aFailureWhileTheFailedGenerationOwnsTheStoreRollsBackAndReports() {
50+
assertThat(Generations.onReloadFailure(6, 6))
51+
.isEqualTo(Generations.FailureAction.ROLLBACK_AND_REPORT);
52+
}
53+
54+
@Test
55+
void aForegroundApplyLeavesItsGenerationPendingItsFirstFrame() {
56+
assertThat(Generations.pendingAfterApply(true, 10)).isEqualTo(10);
57+
}
1858

1959
@Test
2060
void rejectsEqualGeneration() {

0 commit comments

Comments
 (0)