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 emitbenchmarks/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:buildThe 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:Ior, 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.AndroidJUnitRunnerThe 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.IngestBenchmarkA 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/.MainActivityEnd 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.AndroidJUnitRunnerEach 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.
Baton