Skip to content

Commit cd119ba

Browse files
fryanpanclaude
andcommitted
ADFA-4128 (4/11): address CodeRabbit review
- F1716-2 heal a half-finished asset merge on the next run - F1716-5 stop answering a VirtualMachineError with another allocation - F1716-7 take the asset length from the descriptor already open - F1716-8 un-commit a resource provider swap that failed to install Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01FstXxJ5cwWPcvmhZ9vJgJ7
1 parent a55a332 commit cd119ba

6 files changed

Lines changed: 132 additions & 6 deletions

File tree

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

Lines changed: 19 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,13 @@ final class AssetExtractor {
2929
/** Marker file beside {@link #CURRENT_DIR} naming the baseline the merged assets belong to. */
3030
static final String BASELINE_MARKER = "baseline.fp";
3131

32+
/**
33+
* Marker file beside {@link #CURRENT_DIR} that exists only while a merge is in flight.
34+
*
35+
* Finding it at the start of the next merge means the previous one died part-way, so the merged dir holds two generations. The baseline marker still matches and later payloads carry only newly-changed files, so nothing else would ever heal it - a wrongly-written file would stay wrong until a forced rebuild.
36+
*/
37+
static final String MERGE_PENDING_MARKER = "merge.pending";
38+
3239
private static final int BUFFER_SIZE = 16 * 1024;
3340

3441
/**
@@ -84,6 +91,8 @@ static int extract(InputStream zipStream, File destDir) throws IOException {
8491
*
8592
* The clear-then-mark order is the safe crash window: a death between the two leaves a mismatched marker, so the next call clears an already-empty dir instead of serving another baseline's assets.
8693
*
94+
* A merge that dies part-way is recovered at the START of the next call, not on the failure path: {@link #MERGE_PENDING_MARKER} is written before the first byte and cleared only after the last, and finding it still there clears the dir. A cleared dir is safe - the provider falls through to the APK's baked-in assets - whereas a half-merged one serves a file from the wrong generation.
95+
*
8796
* @param zipStream
8897
* the changed-assets zip as it arrived over binder; read but never closed
8998
* @param assetsRoot
@@ -101,11 +110,19 @@ static int extractCumulative(InputStream zipStream, File assetsRoot,
101110
}
102111
File providerRoot = currentDir(assetsRoot);
103112
File marker = new File(assetsRoot, BASELINE_MARKER);
104-
if (!baselineFingerprint.equals(readMarker(marker))) {
113+
File pending = new File(assetsRoot, MERGE_PENDING_MARKER);
114+
if (!baselineFingerprint.equals(readMarker(marker)) || pending.isFile()) {
105115
deleteRecursively(providerRoot);
106116
writeMarker(marker, baselineFingerprint);
107117
}
108-
return extract(zipStream, new File(providerRoot, ASSETS_SUBDIR));
118+
writeMarker(pending, baselineFingerprint);
119+
int count = extract(zipStream, new File(providerRoot, ASSETS_SUBDIR));
120+
if (!pending.delete()) {
121+
// The merge itself is complete and correct, but a marker we cannot clear makes the
122+
// next call clear a dir that did not need it. Say so rather than leave it silent.
123+
throw new IOException("cannot clear merge marker " + pending);
124+
}
125+
return count;
109126
}
110127

111128
/**

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

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -75,7 +75,10 @@ public AssetFileDescriptor loadAssetFd(String path, int accessMode) {
7575
try {
7676
ParcelFileDescriptor fd = ParcelFileDescriptor.open(
7777
candidate, ParcelFileDescriptor.MODE_READ_ONLY);
78-
return new AssetFileDescriptor(fd, 0, candidate.length());
78+
// Size from the descriptor, not a second stat of the path: open() pinned an inode,
79+
// and an extraction renaming the file in between would otherwise pair the old
80+
// inode with the new file's length - a short read, or a read past EOF.
81+
return new AssetFileDescriptor(fd, 0, fd.getStatSize());
7982
} catch (FileNotFoundException error) {
8083
// Raced by a concurrent clear; absent and unreadable look the same to the
8184
// framework, which falls through to the baked-in copy.

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

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,20 @@
1717
*/
1818
public class QuickBuildAppComponentFactory extends AppComponentFactory {
1919

20+
/**
21+
* Rethrows a throwable the fallback cannot help with, before anything else runs.
22+
*
23+
* A {@link VirtualMachineError} says the VM is out of a resource the retry needs, so logging it (formatting a message, walking a stack trace) and then re-running the same construction allocates again in exactly the state that cannot afford it - and when the default-loader retry happens to succeed, the error is swallowed outright. {@link LinkageError} is deliberately NOT in this set: a stale-payload {@code NoSuchFieldError} is the case the fallback exists for.
24+
*
25+
* @param error
26+
* what the payload loader threw
27+
*/
28+
static void rethrowIfFatal(Throwable error) {
29+
if (error instanceof VirtualMachineError) {
30+
throw (VirtualMachineError) error;
31+
}
32+
}
33+
2034
/**
2135
* Throws the failure that best explains a component we could not instantiate from either loader: the PAYLOAD one.
2236
*
@@ -96,6 +110,7 @@ public Activity instantiateActivity(ClassLoader cl, String className, Intent int
96110
try {
97111
return super.instantiateActivity(pickLoader(cl, className), className, intent);
98112
} catch (Throwable payloadError) {
113+
rethrowIfFatal(payloadError);
99114
RuntimeLog.e("payload activity instantiation failed for " + className
100115
+ "; using default loader", payloadError);
101116
try {
@@ -129,6 +144,7 @@ public Application instantiateApplication(ClassLoader cl, String className)
129144
try {
130145
application = super.instantiateApplication(pickLoader(cl, className), className);
131146
} catch (Throwable payloadError) {
147+
rethrowIfFatal(payloadError);
132148
RuntimeLog.e("payload application instantiation failed; using default loader", payloadError);
133149
try {
134150
application = super.instantiateApplication(cl, className);
@@ -170,6 +186,7 @@ public ContentProvider instantiateProvider(ClassLoader cl, String className)
170186
try {
171187
return super.instantiateProvider(pickLoader(cl, className), className);
172188
} catch (Throwable payloadError) {
189+
rethrowIfFatal(payloadError);
173190
RuntimeLog.e("payload provider instantiation failed for " + className
174191
+ "; using default loader", payloadError);
175192
try {
@@ -206,6 +223,7 @@ public BroadcastReceiver instantiateReceiver(ClassLoader cl, String className, I
206223
try {
207224
return super.instantiateReceiver(pickLoader(cl, className), className, intent);
208225
} catch (Throwable payloadError) {
226+
rethrowIfFatal(payloadError);
209227
RuntimeLog.e("payload receiver instantiation failed for " + className
210228
+ "; using default loader", payloadError);
211229
try {
@@ -242,6 +260,7 @@ public Service instantiateService(ClassLoader cl, String className, Intent inten
242260
try {
243261
return super.instantiateService(pickLoader(cl, className), className, intent);
244262
} catch (Throwable payloadError) {
263+
rethrowIfFatal(payloadError);
245264
RuntimeLog.e("payload service instantiation failed for " + className
246265
+ "; using default loader", payloadError);
247266
try {

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

Lines changed: 23 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -219,7 +219,17 @@ public void run() {
219219
synchronized (ResourceStore.this) {
220220
ResourcesProvider previous = provider;
221221
provider = next;
222-
installProviders();
222+
try {
223+
installProviders();
224+
} catch (RuntimeException | Error error) {
225+
// Un-commit. The loader still holds the previous set, so the field has to as
226+
// well - leaving the rejected provider there makes the next deploy offer it
227+
// again, and dropping `previous` on the floor leaks a provider that is still
228+
// installed. Closing `next` is safe: it was never installed.
229+
provider = previous;
230+
Streams.closeQuietly(next);
231+
throw error;
232+
}
223233
Streams.closeQuietly(previous);
224234
}
225235
}
@@ -294,7 +304,17 @@ public void run() {
294304
DirectoryAssetsProvider previousDir = assetsDirProvider;
295305
assetsProvider = next;
296306
assetsDirProvider = nextDir;
297-
installProviders();
307+
try {
308+
installProviders();
309+
} catch (RuntimeException | Error error) {
310+
// Same un-commit as applyTableWithLoader: restore the fields the loader still
311+
// reflects, close the pair that never got installed, and let the failure out.
312+
assetsProvider = previous;
313+
assetsDirProvider = previousDir;
314+
Streams.closeQuietly(next);
315+
Streams.closeQuietly(nextDir);
316+
throw error;
317+
}
298318
Streams.closeQuietly(previous);
299319
Streams.closeQuietly(previousDir);
300320
}
@@ -309,7 +329,7 @@ public void run() {
309329
*
310330
* 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.
311331
*
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.
332+
* 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. The result is deliberately NOT returned to the deploy chain - that would make a deploy arriving on a binder thread block on a main-thread round trip in the hot reload path, which is the very thing posting the swap exists to avoid. Each swap un-commits its own fields on failure, so what stays live is a consistent previous generation.
313333
*
314334
* @param swap
315335
* the field swap + setProviders + close of the replaced provider, taking the store's monitor itself

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

Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -43,6 +43,55 @@ private static InputStream zipOf(Map<String, byte[]> entries) throws IOException
4343
@TempDir
4444
Path tempDir;
4545

46+
@Test
47+
void aCompletedMergeClearsThePendingMarker() throws IOException {
48+
File root = tempDir.resolve("assets-root").toFile();
49+
Map<String, byte[]> first = new LinkedHashMap<String, byte[]>();
50+
first.put("kept.txt", "from the first merge".getBytes("UTF-8"));
51+
AssetExtractor.extractCumulative(zipOf(first), root, "fp-1");
52+
53+
assertThat(new File(root, AssetExtractor.MERGE_PENDING_MARKER).exists()).isFalse();
54+
55+
// And because it is clear, the next merge accumulates instead of starting over -
56+
// a marker left behind would silently throw the first payload's files away.
57+
Map<String, byte[]> second = new LinkedHashMap<String, byte[]>();
58+
second.put("added.txt", "from the second merge".getBytes("UTF-8"));
59+
AssetExtractor.extractCumulative(zipOf(second), root, "fp-1");
60+
61+
File assetsDir = new File(AssetExtractor.currentDir(root), AssetExtractor.ASSETS_SUBDIR);
62+
assertThat(readFile(new File(assetsDir, "kept.txt"))).isEqualTo("from the first merge");
63+
assertThat(readFile(new File(assetsDir, "added.txt"))).isEqualTo("from the second merge");
64+
}
65+
66+
@Test
67+
void aMergeThatDiedPartWayIsClearedAtTheStartOfTheNextOne() throws IOException {
68+
File root = tempDir.resolve("assets-root").toFile();
69+
File assetsDir = new File(AssetExtractor.currentDir(root), AssetExtractor.ASSETS_SUBDIR);
70+
Map<String, byte[]> first = new LinkedHashMap<String, byte[]>();
71+
first.put("kept.txt", "from the completed merge".getBytes("UTF-8"));
72+
AssetExtractor.extractCumulative(zipOf(first), root, "fp-1");
73+
74+
// One entry lands, the next one escapes the destination and aborts the merge. The
75+
// dir now holds two generations and the baseline marker still matches, so nothing
76+
// downstream can tell.
77+
Map<String, byte[]> partial = new LinkedHashMap<String, byte[]>();
78+
partial.put("half.txt", "half-written".getBytes("UTF-8"));
79+
partial.put("../evil.txt", "escaped".getBytes("UTF-8"));
80+
assertThrows(IOException.class,
81+
() -> AssetExtractor.extractCumulative(zipOf(partial), root, "fp-1"));
82+
assertThat(new File(assetsDir, "half.txt").exists()).isTrue();
83+
assertThat(new File(root, AssetExtractor.MERGE_PENDING_MARKER).isFile()).isTrue();
84+
85+
Map<String, byte[]> third = new LinkedHashMap<String, byte[]>();
86+
third.put("fresh.txt", "after recovery".getBytes("UTF-8"));
87+
AssetExtractor.extractCumulative(zipOf(third), root, "fp-1");
88+
89+
// Same baseline throughout, so only the pending marker could have forced this clear.
90+
assertThat(new File(assetsDir, "kept.txt").exists()).isFalse();
91+
assertThat(new File(assetsDir, "half.txt").exists()).isFalse();
92+
assertThat(readFile(new File(assetsDir, "fresh.txt"))).isEqualTo("after recovery");
93+
}
94+
4695
@Test
4796
void cumulativeMergeKeepsEarlierPayloadsFiles() throws IOException {
4897
File root = tempDir.resolve("assets-root").toFile();

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

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,13 @@ void aCheckedPayloadFailureKeepsItsOwnType() {
2424
assertThat(thrown).isSameInstanceAs(payloadError);
2525
}
2626

27+
@Test
28+
void aLinkageErrorIsNotFatal() {
29+
// The stale-payload case the default-loader fallback exists for, so it must fall
30+
// through to the retry rather than being rethrown here.
31+
QuickBuildAppComponentFactory.rethrowIfFatal(new NoSuchFieldError("field removed by a stale payload"));
32+
}
33+
2734
@Test
2835
void aRuntimePayloadFailurePropagatesUnwrapped() {
2936
NoSuchFieldError payloadError = new NoSuchFieldError("field removed by a stale payload");
@@ -45,6 +52,17 @@ void aThrowableNoSignatureAllowsIsWrappedWithTheCauseIntact() throws Exception {
4552
assertThat(wrapper.getCause()).isSameInstanceAs(payloadError);
4653
}
4754

55+
@Test
56+
void aVirtualMachineErrorIsRethrownRatherThanRetried() {
57+
OutOfMemoryError fatal = new OutOfMemoryError("payload dex would not fit");
58+
59+
// Retrying the same construction after this would allocate again in exactly the state
60+
// that cannot afford it, and a retry that happened to succeed would swallow it entirely.
61+
assertThat(assertThrows(
62+
OutOfMemoryError.class, () -> QuickBuildAppComponentFactory.rethrowIfFatal(fatal)))
63+
.isSameInstanceAs(fatal);
64+
}
65+
4866
@Test
4967
void oneThrowableAsBothFailuresDoesNotBlowUpOnSelfSuppression() {
5068
RuntimeException error = new RuntimeException("the same instance twice");

0 commit comments

Comments
 (0)