Skip to content

Commit 7b2269e

Browse files
fryanpanclaude
andcommitted
ADFA-4128: qb 06/12 core-deploy — Core slice 2: reload-vs-restart policy, the binder deploy channel, stage-cost telemetry
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Kj9YeCDHGp9DU8LPtfWJ7W
1 parent 1e2eafe commit 7b2269e

47 files changed

Lines changed: 9061 additions & 0 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.
Lines changed: 87 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,87 @@
1+
package org.appdevforall.cotg.quickbuild.data
2+
3+
import java.io.File
4+
import java.util.zip.ZipEntry
5+
import java.util.zip.ZipOutputStream
6+
7+
/**
8+
* Packages changed asset files into the deploy payload zip.
9+
*
10+
* Entry names are asset-relative paths with forward slashes (`data/levels.json`), which is how
11+
* the runtime's asset overlay keys them, so an entry lands 1:1 over the asset it replaces.
12+
*/
13+
class AssetPackager {
14+
/**
15+
* Maps [file] to its path relative to whichever of [assetRoots] contains it, or null if none
16+
* does.
17+
*
18+
* Both sides are normalized first: without that, `<root>/sub/../../evil` passes the raw-text
19+
* containment test and names a zip entry that escapes the asset directory on unpack.
20+
*
21+
* @param file candidate path; need not exist, since containment is decided on the path text
22+
* alone.
23+
* @param assetRoots asset roots to test, in order; the first one containing [file] wins.
24+
* @return the '/'-separated path relative to the matching root, or null when [file] lies under
25+
* none of them (a root itself never matches), never containing a `..` segment.
26+
*/
27+
fun relativeAssetPath(
28+
file: File,
29+
assetRoots: List<File>,
30+
): String? {
31+
val abs = file.absoluteFile.normalize()
32+
for (root in assetRoots) {
33+
val rootAbs = root.absoluteFile.normalize()
34+
val rootPath = rootAbs.path + File.separator
35+
if (abs.path.startsWith(rootPath)) {
36+
return abs.path.removePrefix(rootPath).replace(File.separatorChar, '/')
37+
}
38+
}
39+
return null
40+
}
41+
42+
/**
43+
* Zips [changedFiles] (only those under an asset root) into [outFile].
44+
*
45+
* @param changedFiles this build's changed set, assets and non-assets mixed; entries
46+
* outside every asset root are ignored.
47+
* @param assetRoots the module's asset roots, which name the zip entries.
48+
* @param outFile zip to write; overwritten, and its parent directory is created.
49+
* @return the written zip and the relative entry paths, or null when the changed set
50+
* contains no asset files, in which case callers omit the assets payload entirely.
51+
*/
52+
fun packageAssets(
53+
changedFiles: Collection<File>,
54+
assetRoots: List<File>,
55+
outFile: File,
56+
): PackagedAssets? {
57+
val entries =
58+
changedFiles.mapNotNull { file ->
59+
relativeAssetPath(file, assetRoots)?.let { rel -> rel to file }
60+
}
61+
if (entries.isEmpty()) return null
62+
63+
outFile.parentFile?.mkdirs()
64+
ZipOutputStream(outFile.outputStream().buffered()).use { zip ->
65+
for ((rel, file) in entries.sortedBy { it.first }) {
66+
if (!file.isFile) continue // deleted asset: absence is the signal for v1
67+
zip.putNextEntry(ZipEntry(rel))
68+
file.inputStream().use { it.copyTo(zip) }
69+
zip.closeEntry()
70+
}
71+
}
72+
return PackagedAssets(outFile, entries.map { it.first }.sorted())
73+
}
74+
75+
/**
76+
* A written assets zip and the entry paths inside it.
77+
*
78+
* @property zip the file just written; always exists, even when every changed asset was a
79+
* deletion and the archive is therefore empty.
80+
* @property relativePaths sorted, '/'-separated asset-relative entry names, including deleted
81+
* assets that have no entry in [zip], so this is a superset of the archive's contents.
82+
*/
83+
data class PackagedAssets(
84+
val zip: File,
85+
val relativePaths: List<String>,
86+
)
87+
}
Lines changed: 109 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,109 @@
1+
package org.appdevforall.cotg.quickbuild.domain.reload
2+
3+
import java.io.DataInputStream
4+
5+
/**
6+
* The hierarchy facts of one compiled class file - name, superclass, directly implemented
7+
* interfaces - which is what keeps [DeployPolicy]'s supertype index current across builds.
8+
*
9+
* Parsed by a constant-pool walk rather than a bytecode library; these fields sit right after
10+
* the constant pool, so nothing past the interface list is read. Names are in dot form with
11+
* `$` for nested classes (`com.example.Outer$Inner`).
12+
*
13+
* @property className the class's own FQN in dot form.
14+
* @property superClassName the direct superclass FQN; null only for `java.lang.Object` itself
15+
* and for interfaces, which declare no superclass.
16+
* @property interfaceNames the directly implemented interface FQNs, in declaration order;
17+
* inherited ones are not listed, since the header does not carry them.
18+
*/
19+
data class ClassHeader(
20+
val className: String,
21+
val superClassName: String?,
22+
val interfaceNames: List<String>,
23+
) {
24+
companion object {
25+
// Reading the 0xCAFEBABE class-file magic back as a signed Int is negative, because
26+
// 0xCAFEBABE > Int.MAX_VALUE.
27+
private const val CLASS_MAGIC = -0x35014542 // 0xCAFEBABE
28+
29+
/**
30+
* Parses one class file's header.
31+
*
32+
* @param bytes the whole class file; only the prefix through the interface list is read,
33+
* so a truncated tail is harmless.
34+
* @return the header, or null when the bytes are not a well-formed class file, which
35+
* callers skip rather than failing the build over.
36+
*/
37+
fun parse(bytes: ByteArray): ClassHeader? =
38+
try {
39+
DataInputStream(bytes.inputStream()).use(::parseStream)
40+
} catch (e: Exception) {
41+
// Swallowed because an over-restart is safe, whereas throwing would fail the
42+
// whole build over one unreadable class.
43+
null
44+
}
45+
46+
private fun parseStream(input: DataInputStream): ClassHeader? {
47+
if (input.readInt() != CLASS_MAGIC) return null
48+
input.readUnsignedShort() // minor
49+
input.readUnsignedShort() // major
50+
51+
val constantCount = input.readUnsignedShort()
52+
val utf8 = HashMap<Int, String>()
53+
val classNameIndex = HashMap<Int, Int>()
54+
// Walk the constant pool to collect just what resolves a class name: UTF-8 strings
55+
// (tag 1) and Class entries (tag 7, which point at a UTF-8 slot). Every other entry
56+
// type is skipped by its fixed byte width - we only need names, not the full pool.
57+
var index = 1
58+
while (index < constantCount) {
59+
val tag = input.readUnsignedByte()
60+
when (tag) {
61+
1 -> {
62+
utf8[index] = input.readUTF()
63+
}
64+
65+
7 -> {
66+
classNameIndex[index] = input.readUnsignedShort()
67+
}
68+
69+
8, 16, 19, 20 -> {
70+
input.skipBytes(2)
71+
}
72+
73+
15 -> {
74+
input.skipBytes(3)
75+
}
76+
77+
3, 4, 9, 10, 11, 12, 17, 18 -> {
78+
input.skipBytes(4)
79+
}
80+
81+
5, 6 -> {
82+
input.skipBytes(8)
83+
index++ // longs/doubles occupy two constant-pool slots
84+
}
85+
86+
else -> {
87+
return null
88+
}
89+
}
90+
index++
91+
}
92+
93+
input.readUnsignedShort() // access flags
94+
val thisClass = className(input.readUnsignedShort(), classNameIndex, utf8) ?: return null
95+
val superClass = className(input.readUnsignedShort(), classNameIndex, utf8)
96+
val interfaces =
97+
(0 until input.readUnsignedShort()).mapNotNull {
98+
className(input.readUnsignedShort(), classNameIndex, utf8)
99+
}
100+
return ClassHeader(thisClass, superClass, interfaces)
101+
}
102+
103+
private fun className(
104+
classIndex: Int,
105+
classNameIndex: Map<Int, Int>,
106+
utf8: Map<Int, String>,
107+
): String? = classNameIndex[classIndex]?.let(utf8::get)?.replace('/', '.')
108+
}
109+
}
Lines changed: 94 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,94 @@
1+
package org.appdevforall.cotg.quickbuild.domain.reload
2+
3+
/**
4+
* Kind of a manifest component the proxy app build recorded (setup.json `components`).
5+
*
6+
* The restart closure referred to throughout this file is [DeployPolicy]'s: a
7+
* restart-sensitive component class plus its user-side supertypes and their nested classes,
8+
* any recompile of which forces a proxy-app process restart.
9+
*/
10+
enum class ComponentKind {
11+
/** An `<activity>`; outside the restart closure, since recreate already refreshes it. */
12+
ACTIVITY,
13+
14+
/** A `<service>`; a live instance cannot be swapped, so it forces a process restart. */
15+
SERVICE,
16+
17+
/** A `<receiver>`; outside the restart closure, being instantiated fresh per delivery. */
18+
RECEIVER,
19+
20+
/** A `<provider>`; like a service, a live instance forces a process restart. */
21+
PROVIDER,
22+
23+
/** The custom `Application` class; forces a process restart, and has no proxy class. */
24+
APPLICATION,
25+
}
26+
27+
/**
28+
* The kinds whose live instance a loader swap cannot update, so a recompile inside their
29+
* restart closure forces a process restart ([DeployPolicy]).
30+
*
31+
* One home for the set, because two rules key off it: the restart decision, and the
32+
* [org.appdevforall.cotg.quickbuild.domain.session.QuickBuildNotice.STALE_COMPONENT_HELPERS] warning that fires when one of these merely
33+
* EXISTS and the deploy hot-swapped instead. Both read it through [isRestartSensitive], which
34+
* also applies the [COGO_INJECTED_COMPONENTS] exemption.
35+
*/
36+
val RESTART_SENSITIVE_KINDS: Set<ComponentKind> =
37+
setOf(ComponentKind.SERVICE, ComponentKind.PROVIDER, ComponentKind.APPLICATION)
38+
39+
/**
40+
* The restart-sensitive components CoGo injects into every debuggable app it builds - the
41+
* logsender AAR's service and the provider that installs it - which the restart rule exempts.
42+
*
43+
* Safe because these two classes ship in the BASE APK dex and are absent from every
44+
* per-generation payload dex, which is exactly the daemon's compile output plus the generated
45+
* proxy classes. Payload loaders are parent-first with the APK loader as parent, so every
46+
* generation's proxy resolves the same `Class` object for these supertypes - their identity
47+
* never changes across a hot swap, and the `ClassCastException` the restart rule exists to
48+
* prevent cannot arise from them. The proxies themselves hold no state: `ProxySourceGenerator`
49+
* emits an empty subclass for services and providers.
50+
*
51+
* Keyed on the EXACT class name, never a package prefix or a "library-provided" test: the
52+
* safety comes from these specific classes being absent from the payload, and any library class
53+
* that DID land in the payload would still be redefined per generation. Same shape, and same
54+
* reason, as `ComponentProxiabilityResolver.UNPROXIABLE_BY_NAME` in the Gradle plugin.
55+
*/
56+
val COGO_INJECTED_COMPONENTS: Set<String> =
57+
setOf(
58+
"com.itsaky.androidide.logsender.LogSenderService",
59+
"com.itsaky.androidide.logsender.utils.LogSenderInstaller",
60+
)
61+
62+
/**
63+
* Whether a code deploy must restart the process because of this component: its kind is one a
64+
* loader swap cannot update ([RESTART_SENSITIVE_KINDS]) and it is not one CoGo injected
65+
* ([COGO_INJECTED_COMPONENTS]).
66+
*
67+
* The one home for the rule, because both consumers must agree: exempting it in [DeployPolicy]
68+
* alone would turn every hot swap on an ordinary app into a spurious stale-helpers warning
69+
* about CoGo's own logsender.
70+
*/
71+
fun ComponentInfo.isRestartSensitive(): Boolean = kind in RESTART_SENSITIVE_KINDS && className !in COGO_INJECTED_COMPONENTS
72+
73+
/**
74+
* One manifest component recorded by the proxy app build (setup.json `components`, schema v2).
75+
*
76+
* Carries only what the deploy policy and restart UX need; intent filters, permissions and
77+
* the like transfer verbatim in the manifest and are not duplicated here.
78+
*
79+
* @property kind which manifest tag declared it, which is what decides restart vs recreate.
80+
* @property className the USER class FQN declared in the source manifest.
81+
* @property proxyClass the generated proxy FQN carried in the transformed manifest;
82+
* null for the Application entry (nothing addresses it by manifest name).
83+
* @property launcher true for the launcher activity - its [proxyClass] is the explicit
84+
* relaunch target after a restart-deploy.
85+
* @property supertypes the user-side (project-compiled) superclass chain recorded from
86+
* class headers at proxy app build time; seeds the restart closure's supertype index.
87+
*/
88+
data class ComponentInfo(
89+
val kind: ComponentKind,
90+
val className: String,
91+
val proxyClass: String? = null,
92+
val launcher: Boolean = false,
93+
val supertypes: List<String> = emptyList(),
94+
)
Lines changed: 99 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,99 @@
1+
package org.appdevforall.cotg.quickbuild.domain.reload
2+
3+
/**
4+
* What a successful code-bearing quick build should do to the proxy app.
5+
*
6+
* A loader swap plus activity recreate cannot update a live Service, ContentProvider or
7+
* custom Application instance, so an app that declares one must restart the proxy-app process
8+
* on every code-bearing deploy. Restarting is safe: the relaunched proxy app boots the newest
9+
* persisted generation and binder catch-up reconciles the rest.
10+
*/
11+
sealed interface DeployDecision {
12+
/** Hot swap the loader and recreate the activity - the usual path. */
13+
data object Recreate : DeployDecision
14+
15+
/**
16+
* The app holds [componentClass] (a [kind]) across reloads, so this deploy must restart.
17+
*
18+
* @property kind what the held component is, so the status surface can name it to the user.
19+
* @property componentClass the USER class FQN of a restart-sensitive component the app
20+
* declares; the first one wins, so it names a cause rather than the complete set of them.
21+
*/
22+
data class Restart(
23+
val kind: ComponentKind,
24+
val componentClass: String,
25+
) : DeployDecision
26+
27+
/**
28+
* The installed baseline cannot take this deploy safely (it predates the component
29+
* metadata, so its runtime would ignore a restart request and hot-swap = stale).
30+
* The session must fall back to a full proxy app rebuild, which regenerates the baseline.
31+
*
32+
* @property detail human-readable cause, carried into the fallback's user-facing message.
33+
*/
34+
data class RebuildProxyApp(
35+
val detail: String,
36+
) : DeployDecision
37+
}
38+
39+
/**
40+
* Decides restart vs recreate after a successful compile (see component-proxying-design.md,
41+
* "Restart vs recreate").
42+
*
43+
* The rule is whether the app declares any component whose live instance a loader swap cannot
44+
* update - a [ComponentKind.SERVICE], [ComponentKind.PROVIDER] or custom
45+
* [ComponentKind.APPLICATION] ([RESTART_SENSITIVE_KINDS]). If it declares one, every
46+
* code-bearing deploy restarts the process; if it declares none, every deploy hot swaps.
47+
* Receivers and activities never count: manifest receivers are instantiated fresh per delivery
48+
* through the factory, and activities are covered by recreate. Nor do the components CoGo
49+
* itself injects ([COGO_INJECTED_COMPONENTS]) - they ship in the base APK dex, so no payload
50+
* ever redefines them; without that exemption every app would restart on every save, since
51+
* logsender is injected into every debuggable build.
52+
*
53+
* The rule deliberately does not look at what the compile touched. Every generation ships the
54+
* WHOLE user class set - `DexTool.dex` dexes the compiler's output tree, never a delta - so a
55+
* hot swap re-defines every user class through a fresh loader whatever the edit was. A held
56+
* Service, ContentProvider or custom `Application` keeps the previous copy, and the first cast
57+
* across the two throws `ClassCastException: Foo cannot be cast to Foo`. Keying on the
58+
* recompiled set is what let an activity-only edit crash the app, reproduced on device
59+
* (spike2-repro-restart-jvmti-2026-08-20.md).
60+
*/
61+
class DeployPolicy(
62+
/**
63+
* The baseline's manifest components as the proxy app build recorded them; only their
64+
* [ComponentInfo.kind] and [ComponentInfo.className] are read.
65+
*/
66+
components: List<ComponentInfo>,
67+
/**
68+
* False when the baseline's setup.json predates schema v2: the component list is
69+
* unknowable and that runtime ignores restart requests, so every code-bearing deploy
70+
* returns [DeployDecision.RebuildProxyApp], which regenerates a v2 baseline.
71+
*/
72+
private val componentInfoAvailable: Boolean = true,
73+
) {
74+
/** The declared components a loader swap cannot update; the first one names the cause. */
75+
private val heldComponent = components.firstOrNull { it.isRestartSensitive() }
76+
77+
/**
78+
* Decides what one successful compile's output requires of the running proxy app.
79+
*
80+
* @param changedClassFiles the .class paths this compile emitted, or null when the
81+
* recompiled set is unknown. Read only to spot a compile that emitted nothing at all on a
82+
* baseline with no usable component list; the restart rule itself ignores it, because the
83+
* payload is the whole class set either way (see the class doc).
84+
* @return restart when the app declares a restart-sensitive component, a proxy app rebuild
85+
* when the baseline is too old to honour one, else recreate.
86+
*/
87+
fun decide(changedClassFiles: Collection<String>?): DeployDecision {
88+
if (componentInfoAvailable) {
89+
val held = heldComponent ?: return DeployDecision.Recreate
90+
return DeployDecision.Restart(held.kind, held.className)
91+
}
92+
// A compile that emitted nothing deploys nothing that can stale a component, so it is
93+
// not worth a full proxy app rebuild on an old baseline.
94+
if (changedClassFiles != null && changedClassFiles.isEmpty()) return DeployDecision.Recreate
95+
return DeployDecision.RebuildProxyApp(
96+
"the installed baseline predates component metadata (setup.json schema v2)",
97+
)
98+
}
99+
}

0 commit comments

Comments
 (0)