Skip to content

Commit d24a6bb

Browse files
fryanpanclaude
andcommitted
ADFA-4128 (4/11): address Akash's review
Fixes for every finding on the runtime module, plus two changes that came out of reviewing them. Crash banner. The copy said "New code crashed", which named the one event this banner cannot observe: the CRASHED state is set only from failReload, so it is always the reload machinery that failed, never the user's own code. It also carried a stack summary it had no room for. It now reads Live reload crashed. App is on the last working version. For more info, see Build Output in Code on the Go. and points at the pane where the full text already goes unchanged. At the narrowest width measured on an A56 at 2x font scale that is five rendered lines, four from 28 characters up; MAX_BANNER_LINES stays at 6, one line of slack, because the line a tighter cap drops is the tail of the pointer - which leaves the reader told to look somewhere without the name of the place. Crash report. It walks up to three causes and prints each one's frames, not just its toString. An Android lifecycle crash always arrives wrapped, so the top frames are ActivityThread's every time and the line naming the developer's bug sits in the cause; reporting the message alone named the exception without ever placing it. markGood retry. lastMarkedGoodGeneration was set before the write was attempted, so a failed markGood was never retried and its latch blocked every later one for the process lifetime, and the KDoc's justification was inverted. Clearing the latch on a bare false is not safe either - markGood answers several situations with one false, and persist runs before apply, so meta.json is briefly ahead of the live generation on every deploy. markGoodCanSucceed separates a failed write from a store that moved on, and only the failed write clears. Payload overtake. onPayload is oneway, so a slower older deploy can be overtaken while it reads its payload and then publish itself over the newer one, leaving disk a generation behind the running process until the next cold boot adopts it. PayloadStore.apply already refuses a generation that is not strictly newer, so this could never reach the screen - only disk. PayloadPersistence now keeps the highest generation this process has published and refuses anything older, throwing StalePayloadException so the deploy path can tell a lost race from a broken store and stay silent about it. The bar rises only after the publishing rename, so a persist that threw part-way does not block its own retry. That guard also separates the two cases a generation number alone conflates. A restarted host counter - the project's state dir wiped while the app stays installed - always arrives in a process that has published nothing, so the mark is zero and the low generation is adopted as before. Both counter-restart tests now build a fresh store object over the same directory, which is the only shape that case has on a device. Payload memory. The resource apk and the assets zip were read whole into memory and written straight back out to files that are reopened as files afterwards, so a cold deploy held two payload-sized arrays live for no benefit on the devices least able to spare them. persist now takes both as streams and copies them through a 16 KB buffer into the same temp-then-fsync-then-rename write. Only the dex stays a byte array, because InMemoryDexClassLoader needs one. The 64 MB cap is unchanged and now guards the streaming path; the parameter types are what keep it that way. Also from Akash: the manifest-merger comment, the KDoc corrections, and the test helper that divided length by width - it modelled a renderer that breaks mid-word, so it read the banner's six real lines as five and could not have caught the overflow it existed for. It wraps on words now, and was watched failing at the old cap before the cap moved. Both new gates were watched red first: the payload cap with its check stubbed out, the overtake refusal with its condition forced false. Only the intended test failed each time. 248 tests green. Banner inset. Photographing the new banner at 2x font scale showed it drawing over the status bar: getRootWindowInsets() comes back null on the first render after a config-change recreate, and the overlay took that as a 0 inset. A null read now means "not measured yet" - the margin is left alone and re-read after the next layout, once, by a listener that removes itself. The decision is a pure static so it can be unit-tested; the deferred re-read firing is checked on a device (A56: banner flush below the 101 px bar at 1.0 and 2.0). Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Xsc7AMGBVyEMfrwpZX87iC
1 parent f3a618b commit d24a6bb

22 files changed

Lines changed: 1172 additions & 172 deletions

‎quickbuild/runtime/build.gradle.kts‎

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -37,9 +37,9 @@ android {
3737
// map parsing, asset extraction). Mirrors :quick-build's jupiter setup.
3838
tasks.withType<Test> {
3939
useJUnitPlatform()
40-
// StreamsTest exercises the 256 MB payload cap through the default readFully
41-
// overload; a capped reader legitimately buffers up to the cap before throwing,
42-
// which overflows Gradle's default 512 MB test-worker heap.
40+
// StreamsTest exercises the payload cap through the default readFully overload; a
41+
// capped reader legitimately buffers up to the cap and then copies it, so the peak is
42+
// about twice the cap - more headroom than Gradle's default 512 MB test-worker heap.
4343
maxHeapSize = "1g"
4444
}
4545

Lines changed: 95 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,95 @@
1+
package com.itsaky.androidide.quickbuild.runtime;
2+
3+
/**
4+
* Turns a payload crash into the text CoGo is told about it, and owns the height its banner may reach.
5+
*
6+
* One summary, one reader. {@link #forReport} crosses binder to CoGo, which has a screen, a scrollback and the developer's attention, so it carries enough frames to place the fault. The banner over the user's own app carries none of it - it names Build Output and stops - so what remains here for the banner is {@link #MAX_BANNER_LINES} alone.
7+
*/
8+
final class CrashSummary {
9+
10+
/**
11+
* Lines {@link StatusOverlay}'s banner shows before it ellipsizes, sized against the whole rendered banner.
12+
*
13+
* The crash banner is the tallest state whose text this class controls: a headline of 56 characters and {@code OverlayState.FULL_OUTPUT_POINTER} at 50. Wrapped at 25 characters per line - the narrowest width measured on an A56 at 2x font scale - the headline costs 3 rendered lines and the pointer 2, so the banner is 5 at its tallest.
14+
*
15+
* Five is needed across the 25-to-27 band; from 28 characters up the headline folds into two as well and the banner fits in 4. The cap is left one line above the worst measured case rather than tightened onto it, because the line it would drop is the tail of the pointer - the reader is left told to look somewhere, without the name of the place - and because a font or locale wider than anything measured here should cost a blank line, not a truncated instruction.
16+
*
17+
* An earlier version also put a stack summary on the banner and needed 14 lines to fit it. Dropping the summary is what buys this back, so putting any detail on the banner again means recomputing here rather than raising the cap. {@code StatusOverlay} reads this instead of carrying a number of its own that could drift from it.
18+
*
19+
* One state is outside this arithmetic: {@code BUILD_FAILED}'s detail is a diagnostic line CoGo sends and nothing here caps its length, so a long one still ellipsizes. That predates this budget and is not addressed by it.
20+
*/
21+
static final int MAX_BANNER_LINES = 6;
22+
23+
/** Frames a report to CoGo names; enough to place the fault, short enough to read. */
24+
private static final int MAX_REPORT_FRAMES = 5;
25+
26+
/** Hard cap on the report form, since it crosses binder. */
27+
private static final int MAX_REPORT_LENGTH = 2000;
28+
29+
/**
30+
* Causes the report walks past the top throwable.
31+
*
32+
* An Android lifecycle crash always arrives wrapped - the framework rethrows as "Unable to start activity" - so the top throwable's frames are ActivityThread's and the frame naming the developer's bug is one level down. Reporting the top alone spends the whole budget on frames no reader can act on. Three levels covers a wrapped cause and the two rewraps a build pipeline tends to add; {@link #truncate} is the backstop, and it cuts from the deepest cause, which is the end worth losing.
33+
*/
34+
private static final int MAX_REPORT_CAUSES = 3;
35+
36+
/**
37+
* The full form reported to CoGo.
38+
*
39+
* @param error
40+
* the failure to summarize; must be non-null
41+
* @return the exception and up to {@link #MAX_REPORT_CAUSES} causes, each with up to {@link #MAX_REPORT_FRAMES} frames, truncated to {@link #MAX_REPORT_LENGTH} chars
42+
*/
43+
static String forReport(Throwable error) {
44+
StringBuilder sb = new StringBuilder();
45+
sb.append(error.toString());
46+
appendFrames(sb, error, MAX_REPORT_FRAMES);
47+
// Each cause gets its frames too, not just its toString. The frame a developer needs is
48+
// almost never in the top throwable: the framework wraps a lifecycle crash, so the top
49+
// five frames are ActivityThread's every time and the one line naming the bug sits in
50+
// the cause. Reporting the message alone named the exception without ever placing it.
51+
Throwable cause = error.getCause();
52+
Throwable previous = error;
53+
for (int depth = 0; cause != null && cause != previous && depth < MAX_REPORT_CAUSES; depth++) {
54+
sb.append("\nCaused by: ").append(cause.toString());
55+
appendFrames(sb, cause, MAX_REPORT_FRAMES);
56+
previous = cause;
57+
cause = cause.getCause();
58+
}
59+
return truncate(sb, MAX_REPORT_LENGTH);
60+
}
61+
62+
/**
63+
* Appends at most {@code limit} of {@code error}'s frames, one per line.
64+
*
65+
* @param sb
66+
* the summary under construction
67+
* @param error
68+
* the failure whose trace to read; a trace stripped by the VM is simply empty
69+
* @param limit
70+
* the most frames to append
71+
*/
72+
private static void appendFrames(StringBuilder sb, Throwable error, int limit) {
73+
StackTraceElement[] frames = error.getStackTrace();
74+
int count = Math.min(frames.length, limit);
75+
for (int i = 0; i < count; i++) {
76+
sb.append("\n at ").append(frames[i]);
77+
}
78+
}
79+
80+
/**
81+
* @param sb
82+
* the summary under construction
83+
* @param limit
84+
* the most characters to keep
85+
* @return the summary, cut to {@code limit} characters
86+
*/
87+
private static String truncate(StringBuilder sb, int limit) {
88+
if (sb.length() > limit) {
89+
sb.setLength(limit);
90+
}
91+
return sb.toString();
92+
}
93+
94+
private CrashSummary() {}
95+
}

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

Lines changed: 32 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -19,33 +19,26 @@
1919
@TargetApi(30)
2020
final class DirectoryAssetsProvider implements AssetsProvider, Closeable {
2121

22+
private final File root;
23+
2224
/**
23-
* Whether {@code candidate} resolves strictly inside {@code root}.
24-
*
25-
* Both sides are canonicalized, so {@code ..} segments and symlinks resolve before the comparison rather than being compared as text. The trailing separator is what stops a sibling whose name merely starts with the root's - {@code /a/rootEvil} against root {@code /a/root} - and it also excludes {@code root} itself.
26-
*
27-
* @param root
28-
* the override directory being served
29-
* @param candidate
30-
* a path resolved against it
31-
* @return true when the candidate may be served; false when it escapes, or when either path cannot be canonicalized - unresolvable counts as outside, since a path this process cannot resolve is one it must not serve
25+
* The root's canonical path with a trailing separator, resolved once: {@code root} is final, so its canonical form cannot change over the provider's lifetime. Null when the root could not be canonicalized, which refuses every lookup.
3226
*/
33-
static boolean isWithinRoot(File root, File candidate) {
34-
try {
35-
return candidate.getCanonicalPath().startsWith(root.getCanonicalPath() + File.separator);
36-
} catch (IOException error) {
37-
return false;
38-
}
39-
}
40-
41-
private final File root;
27+
private final String rootPrefix;
4228

4329
/**
4430
* @param root
4531
* the directory to serve, laid out as an APK root (asset files under {@code assets/})
4632
*/
4733
DirectoryAssetsProvider(File root) {
4834
this.root = root;
35+
String prefix;
36+
try {
37+
prefix = root.getCanonicalPath() + File.separator;
38+
} catch (IOException unresolvableRoot) {
39+
prefix = null;
40+
}
41+
this.rootPrefix = prefix;
4942
}
5043

5144
/** Nothing held open between lookups; here so {@link ResourceStore} can treat providers uniformly. */
@@ -66,7 +59,7 @@ public AssetFileDescriptor loadAssetFd(String path, int accessMode) {
6659
File candidate = new File(root, path);
6760
// Same containment rule as AssetExtractor: the path arrives from outside
6861
// this process's control and must not resolve outside the override dir.
69-
if (!isWithinRoot(root, candidate)) {
62+
if (!isWithinRoot(candidate)) {
7063
return null;
7164
}
7265
if (!candidate.isFile()) {
@@ -85,4 +78,24 @@ public AssetFileDescriptor loadAssetFd(String path, int accessMode) {
8578
return null;
8679
}
8780
}
81+
82+
/**
83+
* Whether {@code candidate} resolves strictly inside the served root.
84+
*
85+
* Both sides are canonicalized, so {@code ..} segments and symlinks resolve before the comparison rather than being compared as text. The root's half is resolved in the constructor instead of here, because this runs for every asset the app opens and this provider sits ahead of the baked-in APK in the loader's list. The trailing separator is what stops a sibling whose name merely starts with the root's - {@code /a/rootEvil} against root {@code /a/root} - and it also excludes the root itself.
86+
*
87+
* @param candidate
88+
* a path resolved against the root
89+
* @return true when the candidate may be served; false when it escapes, or when either path could not be canonicalized - unresolvable counts as outside, since a path this process cannot resolve is one it must not serve
90+
*/
91+
boolean isWithinRoot(File candidate) {
92+
if (rootPrefix == null) {
93+
return false;
94+
}
95+
try {
96+
return candidate.getCanonicalPath().startsWith(rootPrefix);
97+
} catch (IOException error) {
98+
return false;
99+
}
100+
}
88101
}

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

Lines changed: 17 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,13 @@
77
*/
88
final class OverlayState {
99

10+
/**
11+
* Where a reader goes for the whole failure, named on the crash banner.
12+
*
13+
* The banner is a strip over the user's own app and deliberately cannot scroll, so it names the surface that holds the failure rather than carrying any of it. Build Output is the pane CoGo already writes the reported summary to, so this points at something that exists rather than something we would have to build. Its length is half of {@link CrashSummary#MAX_BANNER_LINES}' arithmetic - lengthening it without revisiting that clips the pointer itself, which is the one line the banner cannot afford to lose.
14+
*/
15+
static final String FULL_OUTPUT_POINTER = "For more info, see Build Output in Code on the Go.";
16+
1017
/**
1118
* State for a compile error, carrying the message summary the banner names. The banner is position-free by design: the error location is CoGo's to show, so it never crosses the deploy channel.
1219
*
@@ -30,14 +37,14 @@ static OverlayState building(long runningGeneration) {
3037
}
3138

3239
/**
33-
* State for a payload that crashed and was rolled back, with a stack summary as {@code detail}.
40+
* State for a payload that crashed and was rolled back.
41+
*
42+
* The banner takes no stack summary. It covers the user's own app, so it says what happened and names where the detail is, and stops; {@link CrashSummary#forReport} still ships the whole thing to CoGo. A summary here would be text nobody can scroll, on a surface that ellipsizes, in front of an app the reader did not ask us to cover.
3443
*
35-
* @param detail
36-
* one-line summary of the crash, appended to the banner; null renders the headline alone
3744
* @return the crash state
3845
*/
39-
static OverlayState crashed(String detail) {
40-
return new OverlayState(Kind.CRASHED, detail, 0, -1);
46+
static OverlayState crashed() {
47+
return new OverlayState(Kind.CRASHED, null, 0, -1);
4148
}
4249

4350
/**
@@ -125,8 +132,11 @@ String text() {
125132
}
126133
return sb.toString();
127134
case CRASHED:
128-
return "New code crashed - app is running the last working version"
129-
+ (detail == null ? "" : "\n" + detail);
135+
// "Live reload crashed", not "new code crashed": this state is set only from
136+
// failReload, so it is the reload machinery that failed, never the user's own
137+
// code. The old wording named the one event this banner cannot observe.
138+
return "Live reload crashed. App is on the last working version." + "\n"
139+
+ FULL_OUTPUT_POINTER;
130140
case REINSTALL_PENDING:
131141
return "Update needs your OK in Code on the Go - switch back to approve it\n"
132142
+ "This app is running the last working version";

0 commit comments

Comments
 (0)