Skip to content

Commit 232fb38

Browse files
fryanpanclaude
andcommitted
ADFA-4128: qb 03/12 protocol — The daemon wire format — the contract the IDE and compile daemon share
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Kj9YeCDHGp9DU8LPtfWJ7W
1 parent 58e5809 commit 232fb38

7 files changed

Lines changed: 1183 additions & 0 deletions

File tree

‎quickbuild/protocol/README.md‎

Lines changed: 179 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,179 @@
1+
# Quick Build wire formats (`:quickbuild:protocol`)
2+
3+
Quick Build runs in three processes: CoGo (the IDE), the compile daemon (a JVM child process),
4+
and the runtime living inside the user's proxy app. This module holds the wire types the first
5+
two share, and a change to any format below is a change to three processes at once - which are
6+
**not** upgraded together.
7+
8+
Start at [`../README.md`](../README.md) for what Quick Build is and how a save flows through it.
9+
10+
**This is deliberately not a field reference.** Every message shape is declared in code, linked
11+
per section. What lives here is only what the code cannot tell you: invariants, traps, and the
12+
compatibility rules.
13+
14+
## Three formats, and only one of them lives in this module
15+
16+
| Format | Transport | Declared in |
17+
| --- | --- | --- |
18+
| Daemon protocol | line-delimited JSON on the daemon's stdin/stdout | [`DaemonProtocol.kt`](src/main/kotlin/org/appdevforall/cotg/quickbuild/protocol/DaemonProtocol.kt) |
19+
| Deploy metadata | `metadataJson` on `IQuickBuildTarget.onPayload` | [`PayloadDeployer.kt`](../core/src/main/java/org/appdevforall/cotg/quickbuild/service/deploy/PayloadDeployer.kt) writes, [`DeployMetadata.java`](../runtime/src/main/java/com/itsaky/androidide/quickbuild/runtime/DeployMetadata.java) parses |
20+
| Build status | `statusJson` on `IQuickBuildTarget.onBuildStatus` | [`BuildStatusJson.kt`](../core/src/main/java/org/appdevforall/cotg/quickbuild/service/deploy/BuildStatusJson.kt) writes, [`BuildStatus.java`](../runtime/src/main/java/com/itsaky/androidide/quickbuild/runtime/BuildStatus.java) parses |
21+
22+
Only the daemon protocol has shared types, because both its ends compile against this module - and
23+
since the wire itself is untyped JSON, the module holds the **names** too: `DaemonOps`,
24+
`RequestKeys` and `ResponseKeys` are the op and field-name constants both ends use, so renaming one
25+
is a compile error rather than a runtime one.
26+
27+
`:quickbuild:runtime` does not compile against this module, so the two binder formats are an
28+
encoder/parser *pair*: adding a field means editing both files, and **no compiler will tell you if
29+
you edit only one.**
30+
31+
## Daemon protocol: one JSON object per line, one reply per request
32+
33+
### Transport rules
34+
35+
- **Stdout carries protocol only.** [`DaemonMain`](../daemon/src/main/kotlin/org/appdevforall/cotg/quickbuild/daemon/DaemonMain.kt)
36+
captures the real stdout for responses and redirects `System.out` to stderr, because the
37+
in-process Kotlin compiler prints to stdout and one stray line would corrupt the stream.
38+
- **One request in flight.** CoGo serializes every call behind a mutex, and the daemon's loop is
39+
single-threaded on purpose.
40+
- **Exit contract:** build errors never exit; a malformed line answers `ok:false` and the loop
41+
keeps serving. `shutdown` or stdin EOF exits 0. Only a fatal internal error exits non-zero,
42+
which CoGo reads as daemon death and respawns from.
43+
- The daemon has no log file - progress goes to stderr, which CoGo drains and re-logs
44+
([debugging.md](../docs/debugging.md#the-daemon-has-no-log-of-its-own)).
45+
46+
### Requests
47+
48+
Six ops: `configure`, `compile`, `dex`, `relink`, `ping`, `shutdown`.
49+
50+
- **`configure` opens the session** and fixes everything constant for it; a repeat replaces that
51+
state, so there is no reconfigure op. It existence-checks every classpath entry, plugin and tool,
52+
so a missing file fails at session start rather than mid-build. The build ops answer `ok:false`
53+
if no `configure` ran.
54+
- **The caller supplies every tool path; the daemon never guesses one.** `aapt2`, `d8Jar` and
55+
`androidJar` are required, and `configure` answers `ok:false` with one diagnostic per missing
56+
field. A guessed path could compile against another SDK's `android.jar` and only fail on device.
57+
58+
### Responses
59+
60+
`{"id", "ok", <op values, flat>, "diagnostics"}`. Values are flat scalars, never nested.
61+
62+
- `diagnostics` **can appear on success** - a build can succeed with warnings.
63+
- **`line` and `column` are JSON numbers, and they stop here.** CoGo keeps error positions for
64+
its own surfaces (Build Output, jump-to-error); the build-status format below is deliberately
65+
position-free.
66+
- **An absent `classesChanged` means *unknown*; an empty array means *nothing changed*.** The
67+
deploy policy treats unknown conservatively; the two are not interchangeable.
68+
- **An absent output path (`classesDir`, `dexFile`, `resourcesArsc`) fails the op.** The key is
69+
mandatory on purpose: a conventional fallback under `outDir` would resolve whatever the previous
70+
build left there, so the client would dex and deploy stale artifacts and report success with the
71+
user's edit missing. `resourcesArsc` is the full relinked resource **apk**, not a bare table; the
72+
key keeps its old name for protocol stability.
73+
74+
### Per-build statistics: what the op did, not just how long two compilers took
75+
76+
The compiler spans are only about half a warm edit `[measured on a56]`; the rest is output-tree
77+
snapshots, the Java-ABI re-parse and per-file I/O. The counters (`CompileStats` / `DexStats`) exist
78+
so that gap cannot be misread - javac looked like "the bottleneck" at 19-27% of a warm edit, and a
79+
53 s first build looked like a per-edit cost when it was the cold compile seeding caches. Every
80+
field is a counter or duration derived from no path, name or content, so the set is safe to forward
81+
to analytics.
82+
83+
Two are context rather than cost, and a timing row is unreadable without them: **`compileOrdinal`**
84+
(`1` is the cold build; a fresh `configure`, including a respawn, correctly restarts the count) and
85+
**`scratchFsType`** (rewriting the same class tree costs ~52x more on FUSE-backed emulated storage
86+
than on the app's own filesystem `[measured on a56]`, so durations cannot be compared across
87+
configurations without it).
88+
89+
**Absent versus zero.** `fromValues` returns `null` when *none* of a group's keys are present, so an
90+
older daemon never yields a zero-filled row reading as "measured, and free". Within a group a single
91+
missing key defaults to `0`, indistinguishable from a measured zero.
92+
93+
### `protocolVersion` is a session gate, and adding a key must not bump it
94+
95+
CoGo aborts the session when `configure`'s `protocolVersion` differs **or is absent** - absence
96+
fails too, because the daemon has stamped it since the protocol existed, so a missing field means
97+
"not our daemon".
98+
99+
**Adding a response key is additive and must NOT bump the version.** A staged daemon jar can lag
100+
the client that talks to it, so bumping for a new optional key would break exactly the pairing the
101+
additive shape exists to support.
102+
103+
### Adding a numeric stat touches five places, and the codec is not one of them
104+
105+
`ProtocolCodec.encode` is generic over the values, so it needs no change. The five all sit in
106+
`CompileStats` / `DexStats`: the property, its `KEY_*` constant, `toValues()`, `fromValues()`, and
107+
**the private `KEYS` list** - `fromValues` uses that list to decide "no keys present at all", so
108+
missing it quietly weakens the absent-versus-zero guarantee above.
109+
110+
**The analytics path has a hard cap:** Firebase allows 25 parameters per event, the reload bundle
111+
is already near it, and a test enforces the bound. A new parameter may force merging an existing
112+
one - the path is lossy on purpose.
113+
114+
## Deploy metadata JSON (`IQuickBuildTarget.onPayload`)
115+
116+
Sent with every payload; the dex, resources and assets travel beside it as ParcelFileDescriptors.
117+
118+
- `restart` marks a deploy whose recompiled set touched a service, provider or custom
119+
`Application`: the runtime persists, acks and exits instead of hot-swapping, and CoGo relaunches
120+
into the persisted generation. Decided in [`DeployPolicy.kt`](../core/src/main/java/org/appdevforall/cotg/quickbuild/domain/reload/DeployPolicy.kt),
121+
contract in [`component-proxying-design.md`](../docs/component-proxying-design.md).
122+
- **`restart` matches the exact string `"true"`.** Any other value - `"TRUE"`, a JSON boolean -
123+
reads as false.
124+
- **The `generation` argument travels beside this string, not inside it**, and a payload applies
125+
only when strictly newer than the generation the app runs.
126+
127+
## Build status JSON (`IQuickBuildTarget.onBuildStatus`)
128+
129+
A compile error produces no payload, so this is how a running proxy app learns a build failed.
130+
Kinds: `build_failed`, `build_ok`, `building`, `reinstall_pending`.
131+
132+
- **Every value is a string, including the numbers.** The runtime's hand-rolled
133+
[`MiniJson`](../runtime/src/main/java/com/itsaky/androidide/quickbuild/runtime/MiniJson.java)
134+
keeps only strings and arrays of strings; it consumes numbers, booleans, nulls and nested objects
135+
so extra fields still parse, but **drops those keys entirely** - send a JSON number and the
136+
runtime sees an absent field. Unknown kinds and fields are ignored, so CoGo can extend the schema
137+
without breaking installed proxy apps.
138+
- `build_failed` reports one error, not a log - the first ERROR's first message line only, with
139+
`moreErrors` counting the rest. **No file, line or column**: the overlay is a "your build failed,
140+
this app is stale" warning, and locating the error is CoGo-side work, so position data never
141+
crosses to the device.
142+
- `reinstall_pending` is kind-only: a rebuilt update is waiting on an install confirmation that
143+
Android will only show while CoGo is foregrounded, so the overlay tells the user - the one
144+
person the CoGo-side signals cannot reach - to switch back. The copy is static and lives
145+
runtime-side. An older runtime ignores the unknown kind, so the banner is simply absent there.
146+
147+
## Version skew is normal here, and each format handles it differently
148+
149+
A staged daemon jar can lag CoGo; the runtime AAR is compiled **into** the proxy app, so it only
150+
changes after a proxy app rebuild and reinstall - reinstalling CoGo alone changes nothing in a
151+
running app.
152+
153+
| Change | Effect on an older peer | Safe? |
154+
| --- | --- | --- |
155+
| Add a daemon response key | older client ignores it; newer client reads absent as null | yes, and must not bump `protocolVersion` |
156+
| Add a daemon request field | must be optional; the codec rejects an unknown required field as malformed | yes if optional |
157+
| Bump `protocolVersion` | `configure` aborts the session with a mismatch error | breaking, by design |
158+
| Add a deploy-metadata or build-status field | older runtime ignores it | yes, if the value is a string |
159+
| Add a build-status kind | older runtime parses it to null and drops the message; its banner is simply absent | yes |
160+
| Remove a build-status field | older runtime reads it as absent, same as a field CoGo never populated | yes; no version to bump - the build-status format carries none |
161+
| Add an AIDL method | append at the end only; an older `oneway` stub answers "not handled" and the caller never notices | yes |
162+
| Reorder or remove an AIDL method | silently calls the wrong transaction | never do this |
163+
164+
A protocol regression that compiles is caught by no test: see the known gap in
165+
[README, "How to Test"](../README.md#how-to-test). The name constants close the narrow half of
166+
that gap - a renamed op or field no longer compiles on both sides - but nothing checks that a
167+
*value's meaning* stayed the same, and the two binder formats have no shared names at all.
168+
169+
## Key files
170+
171+
| File | Role |
172+
| --- | --- |
173+
| [`DaemonProtocol.kt`](src/main/kotlin/org/appdevforall/cotg/quickbuild/protocol/DaemonProtocol.kt) | request/response types, stat groups, the op and field-name constants, `PROTOCOL_VERSION` |
174+
| [`ProtocolCodec.kt`](../daemon/src/main/kotlin/org/appdevforall/cotg/quickbuild/daemon/protocol/ProtocolCodec.kt) | parse and encode |
175+
| [`RequestRouter.kt`](../daemon/src/main/kotlin/org/appdevforall/cotg/quickbuild/daemon/protocol/RequestRouter.kt) | dispatch, plus the backstop that keeps a build error from killing the process |
176+
| [`DaemonMain.kt`](../daemon/src/main/kotlin/org/appdevforall/cotg/quickbuild/daemon/DaemonMain.kt) | serve loop, stdout/stderr split |
177+
| [`DaemonService.kt`](../daemon/src/main/kotlin/org/appdevforall/cotg/quickbuild/daemon/DaemonService.kt) | the op implementations that fill the response values |
178+
| [`DaemonProcessClient.kt`](../core/src/main/java/org/appdevforall/cotg/quickbuild/data/DaemonProcessClient.kt) | CoGo's client: spawn, serialize, version gate, unpacking |
179+
| [`IQuickBuildTarget.aidl`](../runtime/src/main/aidl/com/itsaky/androidide/quickbuild/IQuickBuildTarget.aidl) / [`IQuickBuildHost.aidl`](../runtime/src/main/aidl/com/itsaky/androidide/quickbuild/IQuickBuildHost.aidl) | authoritative signatures for both binder directions |
Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,42 @@
1+
plugins {
2+
id("java-library")
3+
id("org.jetbrains.kotlin.jvm")
4+
// Publishes src/testFixtures as a variant consumable by the other quickbuild modules'
5+
// tests. Home of the shared offline-guard scanner: this is the only module all of
6+
// them already depend on.
7+
id("java-test-fixtures")
8+
}
9+
10+
description =
11+
"Quick Build daemon wire-protocol model: the request/response DTOs and protocol constants shared by the daemon and CoGo's client (ADFA-4128)"
12+
13+
java {
14+
sourceCompatibility = JavaVersion.VERSION_17
15+
targetCompatibility = JavaVersion.VERSION_17
16+
}
17+
18+
kotlin {
19+
jvmToolchain(17)
20+
}
21+
22+
tasks.withType<Test> {
23+
useJUnitPlatform()
24+
}
25+
26+
// DoD coverage gate: >=90% line+branch on non-UI (domain/data) code.
27+
// Same wiring as :quickbuild-daemon: the root-applied jacoco plugin auto-creates
28+
// jacocoTestReport for JVM modules with the XML report off and no test dependency;
29+
// the exec lands at the JVM default build/jacoco/test.exec.
30+
tasks.named<JacocoReport>("jacocoTestReport") {
31+
dependsOn(tasks.test)
32+
reports {
33+
xml.required.set(true)
34+
html.required.set(true)
35+
}
36+
}
37+
38+
dependencies {
39+
testImplementation(libs.tests.junit.jupiter)
40+
testImplementation(libs.tests.google.truth)
41+
testRuntimeOnly(libs.tests.junit.platformLauncher)
42+
}

0 commit comments

Comments
 (0)