|
| 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 | |
0 commit comments