Baton

Reference

The Kotlin runtime

The layout of the Kotlin tree, its dependencies, and how it is built and published.

The Kotlin runtime, common first with a platform as an actual, as docs/decisions/the-kotlin-runtime-is-common-first.md records: the store, the ingest, the lenses, retention, connections and persistence in commonMain; the image's engine, the transports and the activity signal as each target's actual. The JVM, through Compose for Desktop, is the development target; Android is the first shipped one, built beside it.

What a runtime does is spec/runtime.md; what it is held to is the manifest, the sources, the cases and the scripts under spec/. The host API is designed in the private notes before it is built.

Using it#

The runtime's files mirror the Swift runtime's, a file per concept, as docs/runtimes.md maps them.

From 0.13.0 the runtime is on Maven Central, com.shergin.baton:baton (JVM and Android), with baton-testing and baton-inspector (both JVM and Android) beside it, and the Gradle plugin com.shergin.baton runs the compiler over a module's hosts with every input and output declared, fetching the release's compiler bundle on first use. docs/recipes/gradle.md is the page; the modules here generate through the same plugin, applied from the included build baton-gradle.

Layout#

baton-gradle/     the Gradle plugin, com.shergin.baton: BatonGenerate, the command with its inputs and outputs
                  declared, and the compiler from BATON_COMPILER or the release's bundle; an included build
release.properties
                  the release the artifacts and the plugin are versioned with and its bundle's checksum,
                  stamped by the release workflow
baton/            the runtime: commonMain; jvmSharedMain, what the JVM and Android share; jvmMain and
                  androidMain; jvmTest runs the spec, jvmRelayTest spec/relay and jvmFateTest spec/fate,
                  androidDeviceTest the image and the ingest benchmark
baton-testing/    the transports an app's tests run over, scripted, recorded, silent, and the socket server double
                  (JVM and Android)
baton-inspector/  StoreInspector, a live view of an environment's store for a debug pane, and StoreExport
goldens/          compiles the Kotlin emitter's goldens, compiler/src/tests/goldens-kotlin, against the runtime,
                  and baton.application, an application's calls into them with no opt-in
samples/shared/   the Rick and Morty sample's screens, Compose Multiplatform for the JVM and Android,
                  its code generated by batonc
samples/desktop/  the Compose for Desktop app over them: window, menu, inspector pane, screenshots
samples/android/  the Android app over them, for a phone
samples/apollo-android/
                  the Apollo Kotlin twin of samples/android: the same two screens over Apollo Kotlin 5.2.0
                  and its normalized cache, for the end-to-end comparison
samples/exchange/ the exchange of docs/recipes/exchange.md, a wrapper over the transport's one verb an app copies:
                  credentials per attempt, one replay of a challenge, a bounded retry, a deadline; common Kotlin
samples/github/   a Compose for Desktop app over GitHub's API, with writes: the twin of examples/GitHubTriage,
                  sending through samples/exchange
benchmarks/android/
                  the ingest benchmark, an instrumented test of a release build that is not debuggable
benchmarks/apollo-comparison/
                  Baton and Apollo Kotlin side by side on the Fixture operation, on the JVM (runComparison)
                  and, through its android/ application, on a device in a process that is not debuggable
benchmarks/macro/ both Android samples end to end on a device, a Macrobenchmark module; server/ is the fixed
                  server both apps fetch from in a measured launch, and the trace sections they emit

benchmarks/apollo-comparison is the Kotlin twin of the repository's benchmarks/apollo-comparison: Apollo Kotlin and its normalized cache, configured as their documentation says, against Baton on the same query and the same graph, each step's median and best printed, with the record in BENCHMARKS.md.

scripts/check-kotlin-goldens.sh runs :goldens:compileKotlinJvm.

baton, baton-testing, baton-inspector and baton-gradle publish through com.vanniktech.maven.publish: gradle publishToMavenLocal, here and in baton-gradle, puts them in ~/.m2 for a project to try, unsigned; the release workflow publishes them to Maven Central from the tag, signed with the key its secrets hold.

Dependencies#

commonMain depends on the Kotlin standard library, kotlinx-coroutines, the Compose runtime and the AndroidX SQLite driver API (androidx.sqlite:sqlite, interfaces the image is written against), and nothing else. jvmMain adds the bundled SQLite engine (androidx.sqlite:sqlite-bundled), since the JVM has none of its own; androidMain reaches the system's through AndroidSQLiteDriver (androidx.sqlite:sqlite-framework). The HTTP transport is java.net alone, in jvmSharedMain, which both targets compile. Nothing in the runtime, its modules or the generated Kotlin uses reflection, serialization or a lookup by name, so an app's R8 or ProGuard configuration needs no rules for Baton, and the artifacts ship none.

On Android the runtime starts at API 23, the lowest the Compose runtime and AndroidX SQLite allow, and compiles against API 36. Modules with Compose UI, the inspector and the samples, compile against API 37, which Compose UI 1.12 asks of what depends on it. The runtime and its modules are compiled with a JDK 21 toolchain and carry Java 21 class files on the JVM and on Android, pinned in their builds; the floors and why are docs/decisions/kotlin-floors.md.

Building#

A JDK 21 and Gradle 9; the project's gradle.properties sets nothing machine-specific, so point JAVA_HOME at the JDK. The tests run the specification through the code batonc generates from spec/sources, so build the compiler first; the build takes the binary BATON_COMPILER names, as a Gradle property or in the environment, else compiler/target/release/batonc, else compiler/target/debug/batonc:

(cd ../compiler && cargo build --release)
JAVA_HOME=$(/usr/libexec/java_home -v 21) gradle :baton:build

The modules generate through the Gradle plugin, built from baton-gradle as an included build; the root build points it at the checkout's compiler, where an adopter's build would fetch the release's bundle.

:baton:build runs three JVM test tasks over one test compilation, each in a process of its own, because the registry numbers types by name for the whole process and Relay's test schema and fate's both declare a User: jvmTest, the specification's cases and scripts through the Kotlin generated from spec/sources; jvmRelayTest, the cases and scripts of spec/relay through the Kotlin generated from spec/relay/sources with spec/relay/baton.json; and jvmFateTest, the responses recorded under spec/fate through the Kotlin generated from the host src/jvmTest/kotlin/baton/fate/FateDocuments.kt. Each can be run alone.

The Android targets need the Android SDK: ANDROID_HOME, or sdk.dir=<path> in kotlin/local.properties, which git ignores. The Android Gradle plugin installs a missing platform when the SDK's licences are accepted; otherwise sdkmanager "platforms;android-36" "platforms;android-37.0". :baton:build runs the common tests on the host against Android's API too (testAndroidHostTest).

The ingest benchmark#

IngestBenchmark, in benchmarks/android/src/androidTest, is the bench the ingest budget of docs/decisions/native-runtimes.md is measured with: the Fixture response (spec/rickandmorty/characters-page-1.json, 686 KB, 899 records) ingested through the plan batonc generates from spec/sources/Fixture.graphql and committed into an empty store, 200 runs of warm-up, then the medians of ingest and of commit over 300 runs, logged under the tag BatonIngest with the device's maker, model and Android version.

The budget's number comes from benchmarks/android, a small application built with the release build type (signed with the debug key, not minified) whose instrumented test is the benchmark, so the process under test is not debuggable and ART compiles the runtime as it would an app's. On a connected device (adb devices lists one; ANDROID_SERIAL picks one of several), with the compiler built:

gradle :benchmarks:android:connectedReleaseAndroidTest
adb logcat -d -s BatonIngest:I

or, once both APKs are installed (gradle :benchmarks:android:installRelease :benchmarks:android:installReleaseAndroidTest), with the result in the instrument's own output:

adb shell dumpsys package baton.benchmarks | grep -m1 flags   # no DEBUGGABLE
adb shell am instrument -w -e class baton.IngestBenchmark \
  baton.benchmarks.test/androidx.test.runner.AndroidJUnitRunner

The runtime's device test compiles the same file and runs it in a debuggable process, which is how the Android Gradle plugin builds a library's device test and which ART runs with less optimization:

gradle :baton:connectedAndroidDeviceTest \
  -Pandroid.testInstrumentationRunnerArguments.class=baton.IngestBenchmark

A number from an emulator is not the budget; the budget is a number on a named device, recorded in BENCHMARKS.md with the device, OS and date. The first one, 2026-10-07: a Google Pixel 9 on Android 17 ingests the fixture in 5.1 ms and commits it in 1.3 ms from the release build, and in 19.0 ms and 6.7 ms from the debuggable device-test build. Without class, connectedAndroidDeviceTest also runs the image's tests on the system's SQLite and the double's shortest text on Android's runtime.

The Android sample#

With an emulator running or a device connected:

gradle :samples:android:installDebug
adb shell am start -n baton.sample.android/.MainActivity

End to end on a device#

benchmarks/macro drives the Baton sample (baton.sample.android) and its Apollo Kotlin twin (baton.sample.apollo) through Macrobenchmark and UiAutomator, both release builds (signed with the debug key, not minified, not debuggable, profileable), compiled with CompilationMode.Full(), fifteen iterations of each scenario: a cold start to the list with an empty store, a cold start with the store an earlier launch left on disk, a tap on a row to its detail, a page turn, and the list scrolled. A launch whose intent carries FixedServer.EXTRA (baton.macro.fixedServer) starts benchmarks/macro/server's server on the loopback interface of the app's own process and fetches the queries and the avatars from it: the first page as each client's query text answers it (spec/rickandmorty/characters-page-1.json for Baton, benchmarks/apollo-comparison/fixture-apollo.json for Apollo), and the second page, character 9's episodes and one avatar recorded once from the live API, kept in the server's assets. The apps emit the same asynchronous trace sections (Sections), and the first list drawn reports the activity fully drawn. On a connected phone (not an emulator):

gradle :samples:android:installRelease :samples:apollo-android:installRelease \
  :benchmarks:macro:installBenchmark
adb shell dumpsys package baton.sample.apollo | grep -m1 pkgFlags   # no DEBUGGABLE
adb shell am instrument -w -r -e tests_regex '.*' \
  baton.macro/androidx.test.runner.AndroidJUnitRunner

Each metric's minimum, median, maximum and deviation is in the instrumentation's status; the traces and the JSON are under /sdcard/Android/media/baton.macro. The record is in BENCHMARKS.md.

scripts/check-kotlin-goldens.sh compiles the emitter's goldens against the runtime, the Kotlin hostile-name corpus among them, after a change to the Kotlin emitter or to what generated code names in the runtime; scripts/hostile-name-sweep-kotlin.py compiles the names of fragments and operations, which the corpus cannot hold, with kotlinc against the classpath :goldens:printCompileClasspath prints.

The Compose compiler's reports, which say whether a class is stable and a composable restartable and skippable, are off by default; gradle -PcomposeReports=true :baton:build :samples:desktop:build writes them under each module's build/compose-reports.

Where it stands#

The plan model generated code constructs (Plan.kt, Registry.kt), the wire's values (Value.kt), the operation interfaces (Operation.kt), the host markers (Markers.kt), the generated-code marker and the format (Generated.kt), and the public errors (Errors.kt). The registry numbers types and slots by name for the whole process: one schema family per process, shared by every environment in it.

The store and the ingest, to the dump. A record's cells are plain values, and a slot that was read has a channel beside its cell, Compose snapshot state made at the first read and bumped by a write that changes the slot (Record.kt); the store numbers the keys a session renders, under a lock the ingest shares, and adopts a constant the build names after (Keys.kt); a plan is resolved under the store's keys (Resolution.kt); a response's bytes become a change set by the plan with no tree between (Ingest.kt, Cursor.kt, ChangeSet.kt), deferred parts by their paths (Delivery.kt); the commit writes it, merges connections and applies edge directives (Store.kt); and Store.dump() writes the *.store.json text. The store belongs to the thread that made it.

jvmTest holds it to every case of spec/manifest.json: each case's responses committed into an empty store leave its dump byte for byte, all 62, through the plans batonc generates from spec/sources into the package baton.spec, as an app's build would. A first number, not a benchmark: the Fixture response (686 KB, 899 records) is ingested in about 1.7 ms and committed in about 0.3 ms on JDK 21 on an M1 Pro, the medians IngestTiming prints.

The reads. The anchor's readers are every accessor generated code calls (Lens.kt): a read loads one cell through its channel on the store's thread, so composition registers that slot alone; a missing or wrong-kind value goes to the store's log and reads as null or a zero value; a non-null link with no record reads the type's placeholder, the link alone reported; @required, @catch, @throwOnFieldError and @defer read by Relay's rules. The owner settles a lens's keys, conditions and @arguments once each (Owner.kt). jvmTest reads every reads row of every case through the generated lenses, walking a row's path over their properties, with mapped scalars through the converters spec/tests/baton.json names for BigDecimal, Instant and URI, and asserts the rule each row's note names.

The generated code is whole: a fragment's lens and an operation's Data over the readers, an @inline fragment's value, a connection's state and its loadNext, a @refetchable fragment's refetch(), a mutation's OptimisticResponse and the invoke its MutationAction is called through.

The environment (Environment.kt) and what hangs off it. A request and its standard encoding, and a transport with one verb, a Flow of payloads (Transport.kt); the value-free log (Log.kt). The environment commits on its main dispatcher, the store's thread, where its suspending calls run whatever thread calls them, and reads responses on its ingest dispatcher; a query's fetch checks for cancellation before its commit, a mutation's request and commit run where no cancellation reaches them, and end() cancels, clears and forgets the session. A handle (Handle.kt) applies its fetch policy and derives its phase from its root and its fetch, both read through Compose snapshot state; refetch(), retry() and retain(), whose Hold is released by hand. The store keeps the roots (Roots.kt): retention and the release buffer, ages and staleness, the verdict settled after a batch that changed a null, a link, an error or a deletion, and the collector, which frees the records no root reaches and the numbers no root, scope or fetch holds. The availability check binds lookups in memory (Availability.kt), and a read that finds data missing heals its root once per fetch. baton-testing holds ScriptedTransport, RecordedTransport, SilentTransport and wait.

Optimistic layers (Store.kt). An optimistic response is applied as a layer with an undo log; a server commit under live layers lifts them, applies the payload and re-applies them; the server's answer replaces its layer in that batch, and a failure reverts it. Such a batch is netted: it stages its writes on the records and, at its end, writes into a cell only the value that differs, so a slot changed and changed back tells nobody; a plain server batch under no layer writes its cells as it goes. The collector keeps what a layer wrote and the numbers it carries.

Subscriptions (Subscription.kt). Environment.subscriptionHandle returns a SubscriptionHandle whose first retention opens the stream through the subscriptions transport, EnvironmentError.NoSubscriptionTransport without one; each event commits at the subscription root until the stream's job ends; a failure while retained waits out the fixed backoff and reopens, counted as a resumption; a request error or the server's completion ends it; Environment.isActive parks and resumes the retained streams; the last release closes the stream and drops the root.

Pagination and refetch (Lens.kt). A connection's loadNext and loadPrevious fetch the fragment's refetch query with the lens's variables, the cursor and the fragment's record's id, the loading flag set on the connection record for the duration; a fragment's refetch() fetches it again in place. A lens made by hand has no environment and throws EnvironmentError.OutsideEnvironment.

The image (Persistence.kt, Disk.kt, Row.kt, Hydration.kt). A Persistence(path, version, sizeLimit), or Persistence.named(name) in the user's cache directory (on Android, Persistence.named(name, directory = cacheDir.path), since the runtime holds no Context), keeps the store's records in one SQLite file with the Swift runtime's tables and rows: a row per record, the query root a row per field, each operation's fetch time, the memberships responses taught and the names the store numbered. Every server batch hands the writer a snapshot of what it changed, and the writer, one coroutine on a thread of its own, writes the queue in one transaction, merging a record memory has not read into its row; a transient type, a transient root field, an optimistic layer and a payload under the mutation and subscription roots never reach it. The availability check answers from memory, then with the image at hand in one read transaction, filling what memory lacks as one local batch, and a root this launch has not fetched takes the age the image keeps. A file of another format or version or a corrupt one starts again; a file over its size limit evicts by launch at open; rows a whole launch leaves unread age out, and the names no row uses go with them; removeAll() deletes the file under a marker. The environment's end writes what was handed over and closes the file for the next environment. The engine is the platform's actual (Image.kt): the driver, the file operations, the cache directory, the writer's thread and a lock; on the JVM the bundled SQLite, on Android the system's. androidDeviceTest holds the image on the system's SQLite across launches.

Store(persistence, cacheExpiration, releaseBufferSize) is public, and Environment(transport, subscriptions, store, mainDispatcher, ingestDispatcher, debug) takes it; with debug it prints the missing-data events until a log is set, since common Kotlin has no build configuration of its own.

jvmTest runs every script of spec/manifest.json through one environment and one store over a ScriptedTransport, on a test dispatcher, comparing after each step what the Swift harness compares: the dump, the reads, the slots notified (through Compose's apply observer), the phases, fetches and streams, the log, the bodies sent, the check's answer and what the step threw; a script with an image runs over a file of its own, and its relaunch makes a new store and environment over it. All twelve scripts pass whole. jvmRelayTest runs spec/relay/manifest.json as swift/Tests/BatonRelayTests runs it: every case and script but the unsupported ones, each response committed as a payload into a bare store, the dump compared with Relay's, a check step's answer compared with Relay's, and each reads row read by walking the generated lens, a link lifted to an empty object, a @catch result to its ok and value or errors, a thrown RequiredFieldError or FieldErrors to the kind the row names, and the operation's own outcome from missingRequiredField and fieldErrors; a case with a status runs as a known issue, which fails once it passes, so the two runtimes agree case for case, and RelayTests.kt lists any case where they legitimately differ, none today. jvmFateTest proves the responses recorded under spec/fate as swift/Tests/BatonFateTests proves them: the posts connection, node(id:), postAdd with @prependNode and the graphql-sse stream of fateLiveNode. PersistenceTests holds the image across launches: the fixture read back, another version, the size limit, the transient rules, the names swept, the events and the removal. Every case with an override reads the overriding value under its layer and its own records after the revert; PaginationTests holds loading a page through a lens, which the contract leaves unheld, and SubscriptionTests the backoff, a refusal and a release.

The wire's transports. The parts of a multipart/mixed response and the events of a text/event-stream one are framed in common code (Framing.kt). HttpTransport (jvmSharedMain) posts the encoded body over HttpURLConnection, asks a deferred operation for multipart and a subscription for graphql-sse's event stream, reads its credentials per attempt, fails outside 2xx with the status and body, or with the GraphQLErrors of a request error answered as application/graphql-response+json, and with status 0 without a response; its flow is cold, and a cancellation closes the connection. It uses java.net alone, in jvmSharedMain, which Android compiles too. GraphQLTransportWebSocket (commonMain) speaks graphql-transport-ws, one socket per transport with every stream under an id of its own, closed when its last stream ends, over a WebSocketClient the platform or an edge supplies: JdkWebSocketClient (jvmMain) over java.net.http.WebSocket, which the JVM factory of the same name picks, or OkHttpWebSocketClient from baton-okhttp, since Android's platform has no WebSocket client of its own. That module, the edge over OkHttp, also holds OkHttpTransport, HTTP over an app's OkHttpClient with its interceptors and pool, reading a response as HttpTransport reads it through the public pieces a transport over any client needs: what a request accepts (Request.accept), the request errors of a body (requestErrors), the media type that answers in a GraphQL response (answersInGraphQLResponse) and the two parsers. Environment(url, headers, subscriptions, store) is an environment over HTTP. jvmTest holds them against a com.sun.net.httpserver server and the SocketServer double of baton-testing, a graphql-transport-ws server on plain sockets, both on the loopback interface; the edge's tests run on a device too.

The composables (Compose.kt). LocalBaton provides the environment; rememberQuery(operation, fetchPolicy) returns a QueryState, the query's state in the composition: the handle it resolved the value to, retained while the composable stays and released when it leaves or its composition is abandoned, and the phase, fetch, isRefreshing, isStale, retry() and refetch() that read it; an equal value under the same policy keeps its handle, a changed value or another policy resolves anew, and outside every provider there is no handle and the phase reads Failed(EnvironmentError.NotInjected). The value given is never written to. rememberMutation(RenameMutation) returns the mutation's action, its type inferred from the companion, a MutationType, and rememberSubscription(operation) returns the subscription's handle, its stream open while the composable stays, or null outside every provider. jvmTest holds them in a Compose for Desktop composition: loading then ready, a write recomposing only the composable that read the slot, and the releases. A composition's environment is made on the desktop's event thread and commits there through the main dispatcher, which kotlinx-coroutines-swing provides.

Skipping, as the Compose compiler reports it: every generated lens (stable class baton.sample.CharacterRow_character), LensList, Phase, Fetch, QueryState and MutationAction are stable, a generated operation value is @Immutable, equal by its variables, and every composable of the sample, CharacterRow and PhaseView among them, is restartable skippable with every parameter stable.

The inspector (baton-inspector). StoreInspector(environment) lists the store's records by type, searchable by key or type, each opening onto its fields, values and field errors, with the store's counts above; it reads the store's revision, snapshot state every batch, collection and end moves, so each recomposes it. StoreExport.text is Store.dump(). It reads the store through the opt-in to the runtime's contract with generated code, as Swift's BatonInspector reads it through @_spi(Generated). jvmTest holds it in a composition: a record listed once its response commits, and a later commit's value shown.

samples/desktop is the first sample: the characters of the Rick and Morty API a page at a time, and a character's detail, through rememberQuery and the phase's three cases, over a store that keeps its image, so a relaunch shows the list before the network answers; a screenshot task draws its screens to PNG files without a window. See its README. Its screens are samples/shared, Compose Multiplatform in common code, an avatar decoded by Skia on the JVM and BitmapFactory on Android; samples/android shows them on a phone, the list and then the selected character's detail, back returning to the list, its environment in a view model and its image in the app's cache directory.

samples/github is the second, the twin of examples/GitHubTriage, and the first with writes: sign-in with a token kept in memory, a repository whose star toggle calls addStar or removeStar with an optimistic response, its open issues as a connection that loads the next page when the list reaches its end, an issue whose composer appends an optimistic comment through @appendEdge, and sign-out, which ends the environment and removes the image. Its tests run the screens over ScriptedTransport with responses written for its documents; the live API needs a token and is not part of the build. Two things it asked of the runtime are the runtime's now: a companion an app passes to rememberMutation with no opt-in and no type spelled, and markers that repeat, so its star button hosts its fragment and both mutations.

Android. The runtime and the inspector build for Android; the image runs on the system's SQLite through AndroidSQLiteDriver; the HTTP transport, the thread's identity and the image's file operations, writer and lock are jvmSharedMain; a double's shortest text is searched for, since Android's Double.toString is not the shortest round trip. The sample runs on an emulator and on a phone. IngestBenchmark from a release build on a Google Pixel 9 (Android 17) ingests the Fixture in about 5.1 ms and commits it in about 1.3 ms, the medians of 300 runs; the debuggable device-test build of the same code took 19 ms and 6.7 ms, and the emulator's release numbers are 4.3 ms and 1.1 ms (BENCHMARKS.md). On Android the socket transport is the app's.