Skip to content

Commit 9ae4ae4

Browse files
fryanpanclaude
andcommitted
ADFA-4931: stop a re-stage from truncating the distribution's own jars
Code review found that linkDistJars could destroy the on-device Gradle distribution. createSymbolicLink refuses an existing path, the broad catch swallowed that, and the copy fallback then opened the stale link for write -- which truncates the distribution's jar, not the daemon's copy. Every Gradle build on the device breaks, and the stamp records it as success. Deleting the target before linking removes the class of failure. The rest is the same review's smaller findings: - link failures leave no stamp, now covered by a test that goes red when the stamp is written first - names read from dist-linked-jars.txt must be bare file names, the guard extractDaemonZip already applies twelve lines up - a missing list and an empty one now say which one happened - the copy fallback is reached by injecting the link operation, so the test named for a symlink-less filesystem tests one - the daemon zip's exclusion is scoped to the classpath it is about - two docs bullets said the daemon re-extracts every provision; it re-extracts when the installed APK changed Tested: 304 app quickbuild tests green, spotlessCheck green. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01GwtUCGy8fKaSzu2uuUEUPY
1 parent d669dfb commit 9ae4ae4

6 files changed

Lines changed: 119 additions & 23 deletions

File tree

‎app/build.gradle.kts‎

Lines changed: 2 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,6 @@ import org.adfa.constants.GRADLE_API_NAME_JAR_BR
88
import org.adfa.constants.GRADLE_API_NAME_JAR_ZIP
99
import org.adfa.constants.GRADLE_DISTRIBUTION_ARCHIVE_NAME
1010
import org.adfa.constants.GRADLE_DISTRIBUTION_NAME
11-
import org.adfa.constants.GRADLE_DISTRIBUTION_VERSION
1211
import org.adfa.constants.KOTLIN_VERSION
1312
import org.gradle.nativeplatform.platform.internal.DefaultNativePlatform
1413
import org.json.JSONObject
@@ -626,11 +625,10 @@ val quickBuildDaemonZip =
626625
from(daemonProject.tasks.named("daemonJar"))
627626
// The daemon jar's manifest Class-Path names these by file name; they must sit
628627
// next to the jar after extraction.
629-
from(daemonProject.configurations.named("runtimeClasspath"))
630628
// ADFA-4931: except the ones the distribution already carries; the stager links those
631-
// in at provision time.
629+
// in at provision time. Scoped to this spec so it cannot filter the other from()s.
632630
val linked = quickBuildDistLinkedJars.toSet()
633-
exclude { it.name in linked }
631+
from(daemonProject.configurations.named("runtimeClasspath")) { exclude { it.name in linked } }
634632
from(quickBuildDistLinkedJarList)
635633
// Compose compiler plugin, version-matched to the daemon's compiler; the stable
636634
// name is the contract EnvironmentQuickBuildPaths.composeCompilerPlugin reads.

‎app/src/main/java/com/itsaky/androidide/quickbuild/QuickBuildArtifactStager.kt‎

Lines changed: 33 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ import java.io.FileNotFoundException
1010
import java.io.IOException
1111
import java.io.InputStream
1212
import java.nio.file.Files
13+
import java.nio.file.Path
1314
import java.util.zip.ZipInputStream
1415

1516
/**
@@ -148,13 +149,15 @@ object QuickBuildArtifactStager {
148149
* expects them.
149150
*
150151
* @param gradleDists exists so tests can point at a fake distribution.
152+
* @param createLink exists so tests can force the copy fallback.
151153
* @throws FileNotFoundException if a jar or the list is missing, which fails loudly here
152154
* rather than as a NoClassDefFoundError during the user's first compile.
153155
*/
154156
@Throws(IOException::class)
155157
internal fun linkDistJars(
156158
daemonDir: File,
157159
gradleDists: File = Environment.GRADLE_DISTS,
160+
createLink: (Path, Path) -> Unit = { link, existing -> Files.createSymbolicLink(link, existing) },
158161
) {
159162
val distLib = File(File(gradleDists, GRADLE_DISTRIBUTION_NAME), "lib")
160163
for (jarName in readLinkedJarNames(daemonDir)) {
@@ -167,31 +170,50 @@ object QuickBuildArtifactStager {
167170
}
168171

169172
val target = File(daemonDir, jarName)
173+
// Clear the path first: a link an earlier staging left would make the copy fallback
174+
// follow it and truncate the distribution's own jar.
175+
Files.deleteIfExists(target.toPath())
170176
try {
171-
Files.createSymbolicLink(target.toPath(), source.toPath())
172-
} catch (e: Exception) {
173-
// A filesystem without symlinks still needs these bytes, so copy instead.
174-
log.warn("Symlink {} -> {} failed ({}), copying", target, source, e.toString())
175-
source.inputStream().use { input -> target.outputStream().use { input.copyTo(it) } }
177+
createLink(target.toPath(), source.toPath())
178+
} catch (e: IOException) {
179+
copyInstead(source, target, e)
180+
} catch (e: UnsupportedOperationException) {
181+
copyInstead(source, target, e)
176182
}
177183
}
178184
}
179185

186+
/** Copies [source] to [target] when the filesystem refused a link, so the bytes still arrive. */
187+
private fun copyInstead(
188+
source: File,
189+
target: File,
190+
cause: Exception,
191+
) {
192+
log.warn("Symlink {} -> {} failed ({}), copying", target, source, cause.toString())
193+
source.inputStream().use { input -> target.outputStream().use { input.copyTo(it) } }
194+
}
195+
180196
/** The jar names the build left out of the zip for the distribution to supply. */
181197
private fun readLinkedJarNames(daemonDir: File): List<String> {
182198
val list = File(daemonDir, LINKED_JARS_LIST)
183-
val names =
184-
if (list.isFile) {
185-
list.readLines().map(String::trim).filter(String::isNotEmpty)
186-
} else {
187-
emptyList()
188-
}
199+
if (!list.isFile) {
200+
throw FileNotFoundException(
201+
"$LINKED_JARS_LIST is missing from $daemonDir, so the build never wrote it. It " +
202+
"lists the jars Quick Build links from the Gradle distribution.",
203+
)
204+
}
205+
val names = list.readLines().map(String::trim).filter(String::isNotEmpty)
189206
if (names.isEmpty()) {
190207
throw FileNotFoundException(
191208
"$LINKED_JARS_LIST at $list names no jars. It lists the jars Quick Build links " +
192209
"from the Gradle distribution rather than shipping in the APK.",
193210
)
194211
}
212+
// Each name is used as a path on both sides of the link, so the same boundary
213+
// extractDaemonZip guards applies here: anything but a bare file name escapes.
214+
names.firstOrNull { File(it).name != it }?.let { name ->
215+
throw IOException("Refusing linked jar name that is not a bare file name: $name")
216+
}
195217
return names
196218
}
197219
}

‎app/src/test/java/com/itsaky/androidide/quickbuild/QuickBuildArtifactStagerDistLinkTest.kt‎

Lines changed: 63 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ import org.junit.Test
88
import org.junit.rules.TemporaryFolder
99
import java.io.File
1010
import java.io.FileNotFoundException
11+
import java.io.IOException
1112
import java.nio.file.Files
1213

1314
/**
@@ -84,18 +85,73 @@ class QuickBuildArtifactStagerDistLinkTest {
8485
@Test
8586
fun `a jar that cannot be linked is copied instead, so the daemon still has the bytes`() {
8687
val daemonDir = daemonDirListing()
87-
// However a link is refused, the current bytes still have to end up at this path.
88-
val occupied = File(daemonDir, jarNames.first()).apply { writeText("stale-bytes") }
88+
val refused = jarNames.first()
8989

90-
QuickBuildArtifactStager.linkDistJars(daemonDir, gradleDistsWith())
90+
// Stands in for a filesystem that has no symlinks, which throws rather than returning.
91+
QuickBuildArtifactStager.linkDistJars(daemonDir, gradleDistsWith()) { link, existing ->
92+
if (link.fileName.toString() == refused) throw UnsupportedOperationException("no symlinks here")
93+
Files.createSymbolicLink(link, existing)
94+
}
9195

92-
assertThat(Files.isSymbolicLink(occupied.toPath())).isFalse()
93-
assertThat(occupied.readText()).isEqualTo("${jarNames.first()}-bytes")
96+
assertThat(Files.isSymbolicLink(File(daemonDir, refused).toPath())).isFalse()
97+
assertThat(File(daemonDir, refused).readText()).isEqualTo("$refused-bytes")
9498
// The fallback is per jar, so one refused link must not cost the rest theirs.
9599
assertThat(Files.isSymbolicLink(File(daemonDir, jarNames.last()).toPath())).isTrue()
96100
assertThat(File(daemonDir, jarNames.last()).readText()).isEqualTo("${jarNames.last()}-bytes")
97101
}
98102

103+
@Test
104+
fun `a link refused with an IO error is copied too, not just an unsupported one`() {
105+
val daemonDir = daemonDirListing()
106+
val refused = jarNames.first()
107+
108+
// Stands in for a filesystem that supports links but refuses this one, such as a
109+
// read-only mount or a denied permission.
110+
QuickBuildArtifactStager.linkDistJars(daemonDir, gradleDistsWith()) { link, existing ->
111+
if (link.fileName.toString() == refused) throw IOException("permission denied")
112+
Files.createSymbolicLink(link, existing)
113+
}
114+
115+
assertThat(Files.isSymbolicLink(File(daemonDir, refused).toPath())).isFalse()
116+
assertThat(File(daemonDir, refused).readText()).isEqualTo("$refused-bytes")
117+
}
118+
119+
@Test
120+
fun `a listed name that is a path rather than a file name is refused`() {
121+
// Resolves to a jar that is really there, so only the guard stops the link being made
122+
// one directory above the daemon dir.
123+
val escaping = "../lib/${jarNames.first()}"
124+
val daemonDir = daemonDirListing(names = listOf(escaping))
125+
126+
// The list is extracted from the daemon zip, which extractDaemonZip already refuses to
127+
// let escape; a name used as a path deserves the same guard.
128+
val thrown =
129+
runCatching { QuickBuildArtifactStager.linkDistJars(daemonDir, gradleDistsWith()) }
130+
.exceptionOrNull()
131+
132+
assertThat(thrown).isInstanceOf(IOException::class.java)
133+
assertThat(thrown!!).hasMessageThat().contains("bare file name")
134+
assertThat(thrown).hasMessageThat().contains(escaping)
135+
assertThat(stagedNames(daemonDir)).isEmpty()
136+
}
137+
138+
@Test
139+
fun `staging twice leaves the distribution's own jars intact`() {
140+
val daemonDir = daemonDirListing()
141+
val dists = gradleDistsWith()
142+
QuickBuildArtifactStager.linkDistJars(daemonDir, dists)
143+
144+
// Linking onto the previous run's links: the copy fallback would otherwise open them for
145+
// write, truncating the distribution itself and breaking every Gradle build on the device.
146+
QuickBuildArtifactStager.linkDistJars(daemonDir, dists)
147+
148+
jarNames.forEach { name ->
149+
val inDistribution = File(dists, "$GRADLE_DISTRIBUTION_NAME/lib/$name")
150+
assertThat(inDistribution.readText()).isEqualTo("$name-bytes")
151+
assertThat(File(daemonDir, name).readText()).isEqualTo("$name-bytes")
152+
}
153+
}
154+
99155
@Test
100156
fun `missing distribution fails loudly and names the path a human has to fix`() {
101157
val daemonDir = daemonDirListing()
@@ -116,7 +172,8 @@ class QuickBuildArtifactStagerDistLinkTest {
116172
fun `one listed jar missing from the distribution fails the whole staging`() {
117173
val daemonDir = daemonDirListing()
118174

119-
// A partial link is worse than none: the daemon starts and dies on the missing class.
175+
// The throw is what protects the caller here: the stamp is left unwritten, so the next
176+
// staging wipes the jars this one already linked.
120177
val thrown =
121178
runCatching {
122179
QuickBuildArtifactStager.linkDistJars(daemonDir, gradleDistsWith(jarNames.take(1)))

‎app/src/test/java/com/itsaky/androidide/quickbuild/QuickBuildArtifactStagerTest.kt‎

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -202,6 +202,25 @@ class QuickBuildArtifactStagerTest {
202202
assertThat(jar.exists()).isTrue()
203203
}
204204

205+
@Test
206+
fun `a failed link leaves no stamp so the next stage retries`() {
207+
val daemonDir = File(tmp.newFolder("home-link-fail"), "daemon")
208+
val jar = File(daemonDir, "quickbuild-daemon.jar")
209+
val emptyDists = tmp.newFolder("gradle-dists-empty")
210+
211+
// Stamping a failed link would serve a daemon whose classpath names jars that are not
212+
// there, and the early return would never stage again.
213+
val thrown =
214+
runCatching {
215+
QuickBuildArtifactStager.stageDaemonIfNeeded("7:1000", daemonDir, jar, emptyDists) { daemonZip() }
216+
}.exceptionOrNull()
217+
assertThat(thrown).isInstanceOf(FileNotFoundException::class.java)
218+
assertThat(File(daemonDir, QuickBuildArtifactStager.DAEMON_STAMP_FILE).exists()).isFalse()
219+
220+
val ran = QuickBuildArtifactStager.stageDaemonIfNeeded("7:1000", daemonDir, jar, gradleDists) { daemonZip() }
221+
assertThat(ran).isTrue()
222+
}
223+
205224
@Test
206225
fun `a failed extraction leaves no stamp so the next stage retries`() {
207226
val daemonDir = File(tmp.newFolder("home"), "daemon")

‎quickbuild/docs/debugging.md‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -179,7 +179,7 @@ to CoGo.
179179
| Path | What | How to read it |
180180
| --- | --- | --- |
181181
| `files/home/.cg/quickbuild/quickbuild-runtime.aar` | staged runtime AAR, re-staged every provision | `run-as com.itsaky.androidide` |
182-
| `files/home/.cg/quickbuild/daemon/` | daemon jar, its full runtime classpath, `compose-compiler-plugin.jar`; deleted and re-extracted every provision. The jars named in `dist-linked-jars.txt`, the Kotlin compiler among them, are symlinks into the Gradle distribution's `lib/` rather than extracted files - `ls -l` here, since a broken one surfaces as a `NoClassDefFoundError` on the first compile | `run-as com.itsaky.androidide` |
182+
| `files/home/.cg/quickbuild/daemon/` | daemon jar, its full runtime classpath, `compose-compiler-plugin.jar`; deleted and re-extracted when the installed APK changed. The jars named in `dist-linked-jars.txt`, the Kotlin compiler among them, are symlinks into the Gradle distribution's `lib/` rather than extracted files - `ls -l` here, since a broken one surfaces as a `NoClassDefFoundError` on the first compile | `run-as com.itsaky.androidide` |
183183
| `files/home/.cg/quickbuild/bench-events.jsonl` | session event log; bench flag only | `run-as com.itsaky.androidide` |
184184
| `no_backup/quickbuild-scratch/<name>-<16 hex>/work` | executor payload staging | `run-as com.itsaky.androidide`; **deleted on teardown** |
185185
| `no_backup/quickbuild-scratch/<name>-<16 hex>/out` | daemon output: classes, dex, relinked resources | as above |

‎quickbuild/docs/pipeline.md‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -356,7 +356,7 @@ flowchart TB
356356

357357
Step 7's rebuild fallback re-enters the same runner through `rebuildProxyApp`.
358358

359-
1. [QuickBuildArtifactStager](../../app/src/main/java/com/itsaky/androidide/quickbuild/QuickBuildArtifactStager.kt) extracts the runtime AAR and the daemon zip from CoGo's APK assets into `<ANDROIDIDE_HOME>/quickbuild/`. The zip leaves out the eight jars the on-device Gradle distribution already carries, the Kotlin compiler among them; the stager reads their names from `dist-linked-jars.txt` and symlinks the distribution's copies in beside the daemon jar, copying instead if the filesystem refuses a symlink. It re-extracts on **every** provision on purpose: a version-keyed marker serves a stale bundle when content changes without a version bump.
359+
1. [QuickBuildArtifactStager](../../app/src/main/java/com/itsaky/androidide/quickbuild/QuickBuildArtifactStager.kt) extracts the runtime AAR and the daemon zip from CoGo's APK assets into `<ANDROIDIDE_HOME>/quickbuild/`. The zip leaves out the eight jars the on-device Gradle distribution already carries, the Kotlin compiler among them; the stager reads their names from `dist-linked-jars.txt` and symlinks the distribution's copies in beside the daemon jar, copying instead if the filesystem refuses a symlink. It re-extracts when the installed APK changed, which a stamp file beside the daemon jar records.
360360
2. [GradleQuickBuildProvisioner](../../app/src/main/java/com/itsaky/androidide/quickbuild/GradleQuickBuildProvisioner.kt) runs the proxy app build through CoGo's existing `BuildService.executeTasks`, then parses `setup.json` into [ProxyAppInfo](../core/src/main/java/org/appdevforall/cotg/quickbuild/data/ProxyAppInfo.kt).
361361
3. [QuickBuildScratch](../core/src/main/java/org/appdevforall/cotg/quickbuild/data/QuickBuildScratch.kt) creates `noBackupFilesDir/quickbuild-scratch/<projectKey>/{work,out}` and enforces the free-space floor. It must stay on app-private storage - see the README's "Build Scratch Lives in Faster Private Storage".
362362
4. [QuickBuildClobberCheck](../core/src/main/java/org/appdevforall/cotg/quickbuild/service/provision/QuickBuildClobberCheck.kt) plus [RealIdInstall](../core/src/main/java/org/appdevforall/cotg/quickbuild/domain/reload/RealIdInstall.kt) read the installed package's `android:appComponentFactory` to decide whether the tap clobbers a Standard Run install. Stateless: an install or uninstall outside CoGo cannot leave it stale.

0 commit comments

Comments
 (0)