Engineering

Adding a parameter with a default broke the ABI

Source compatibility and binary compatibility are different guarantees, and a default argument preserves only the first.

MobileByteSensei 9 min read
kmppublishingarchitecture

TL;DR

Adding a parameter with a default value is source-compatible: every existing call site still compiles. It is not binary-compatible, because the compiled method signature changes — and anything already compiled against the old signature fails to link.

We shipped it as a patch release. Consumers who recompiled were fine. Consumers who had our library as a transitive dependency and did not recompile got NoSuchMethodError at runtime.

Source compatibility is about recompiling. Binary compatibility is about not recompiling. If you publish a library, you owe the second one, and a tool can check it for you in CI.

The case this post is about

A published multiplatform library, adding a timeout option:

// 2.3.0
class SyncClient(private val api: Api) {
    suspend fun sync(userId: String): SyncResult { … }
}
// 2.3.1 — "a patch: purely additive, every call site still compiles"
class SyncClient(private val api: Api) {
    suspend fun sync(userId: String, timeout: Duration = 30.seconds): SyncResult { … }
}

Every existing sync(userId) call compiles against 2.3.1. We verified that, shipped it as a patch, and the release notes said "additive".

The symptom

Issues from two consumers within a day:

java.lang.NoSuchMethodError: 'java.lang.Object com.example.SyncClient.sync(
    java.lang.String, kotlin.coroutines.Continuation)'
    at com.consumer.SyncWorker.doWork(SyncWorker.kt:34)

The method they were calling no longer existed in the compiled artifact.

Why it reached them without a recompile:

consumer-app
 └── third-party-sdk:1.4.0        (compiled against sync-client:2.3.0)
      └── sync-client:2.3.0  →  resolved to 2.3.1 by another dependency's constraint

The SDK's compiled bytecode referenced sync(String, Continuation). Gradle resolved sync-client to 2.3.1 — newest-wins — and 2.3.1 does not have that method. The SDK vendor would have had to recompile, which is not something the consumer can do.

And on iOS it failed differently:

Undefined symbols for architecture arm64:
  "_kfun:com.example.SyncClient#sync(kotlin.String;kotlin.coroutines.Continuation){}"

A link-time failure rather than a runtime one — better, and still a broken build for anyone consuming our framework as a binary.

The blast radius:

consumers affected (from issue reports + download stats)
  recompiled against 2.3.1:         unaffected
  consuming us transitively:        2 SDKs, ~40 downstream apps
  2.3.1 yanked after:               19 hours

Why the obvious fix failed

"It compiles, so it is compatible." The entire mistake, in one sentence. Our CI built every consumer sample from source, so every check passed. Source compatibility was verified; binary compatibility was never tested because nothing in the pipeline linked old bytecode against the new artifact.

"Bump the minor version instead." We did, re-releasing as 2.4.0, and semver's minor means "backwards-compatible additions". A binary-incompatible change in a minor release still breaks a transitive consumer who cannot recompile — the version number communicates intent and does not prevent the link error.

"Add @JvmOverloads." This generates the overloads on the JVM only, so Android and desktop consumers are fine and the Kotlin/Native symbol is still gone. On a multiplatform library it fixes one target of four.

"Tell consumers to recompile." A consumer cannot recompile a third-party SDK. The person who needs to act is not the person experiencing the failure, which is what makes this class so expensive.

The actual mechanism

Two different compatibility guarantees, about two different operations:

Source compatibility: existing source code still compiles against the new version. Adding a default parameter preserves this, because the compiler fills in the default at the new call site.

Binary compatibility: existing compiled artifacts still link against the new version. Adding a parameter changes the method's descriptor, so the old symbol disappears.

2.3.0 compiles to:   sync(String, Continuation)
2.3.1 compiles to:   sync(String, Duration, Continuation)
                   + sync$default(String, Duration, int, Continuation)   (the defaults shim)

old bytecode calls:  sync(String, Continuation)          → not present in 2.3.1

The default value is applied at the call site at compile time, not inside the callee. That is the whole mechanism: a default argument is a compiler convenience, so it only helps code that is being compiled.

Across targets it surfaces differently, which is worth knowing:

JVM/Android   NoSuchMethodError      at runtime, on first call    ← worst: late and partial
Kotlin/Native undefined symbol        at link time                 ← better: build fails
JS/wasm       depends on the module system; often a runtime error

What a check for this actually looks like, because "run the validator" hides the one idea worth carrying: the comparison is over symbols, and a symbol that disappeared is breaking no matter how source-compatible the change reads.

/** One declaration line from a `.api` dump — the descriptor, not the Kotlin signature. */
data class Symbol(val owner: String, val descriptor: String)

enum class Verdict { SAFE, BREAKING }

fun classify(old: Set<Symbol>, new: Set<Symbol>): Map<Symbol, Verdict> {
    val verdicts = mutableMapOf<Symbol, Verdict>()

    for (symbol in old) {
        if (symbol in new) continue                    // unchanged — nothing to say

        // A `$default` shim with the same owner is the tell that a parameter was ADDED:
        // the source still compiles, and this exact descriptor is gone for anyone who
        // compiled against it. That is the whole defect, and it is why "we only added a
        // default argument" is not a defence.
        verdicts[symbol] = Verdict.BREAKING
    }

    // Added symbols are safe on their own. They are listed, never judged — a new overload
    // breaks nobody, and treating additions as findings is how a gate gets switched off.
    for (symbol in new - old) verdicts[symbol] = Verdict.SAFE

    return verdicts
}

fun gate(old: Set<Symbol>, new: Set<Symbol>, isPatchRelease: Boolean) {
    val broken = classify(old, new).filterValues { it == Verdict.BREAKING }.keys
    when {
        broken.isEmpty() -> println("abi: ${new.size} symbols, no removals ✓")
        isPatchRelease -> error(
            "abi: ${broken.size} symbol(s) removed in a PATCH release:\n" +
                broken.joinToString("\n") { "  ${it.owner}.${it.descriptor}" },
        )
        else -> println("abi: ${broken.size} removal(s) — permitted, bump the MAJOR version")
    }
}

On the named case — the 2.3.0 → 2.3.1 dumps that shipped:

$ ./gradlew apiCheck

abi: 1 symbol(s) removed in a PATCH release:
  com/acme/sync/Syncer.sync (Ljava/lang/String;Lkotlin/coroutines/Continuation;)Ljava/lang/Object;

The branch is the part that matters. The same removal is a release-blocking defect at 2.3.1 and an expected cost at 3.0.0, so the check reads the intended version rather than treating every removal as an error — a gate that fires on legitimate major releases is one somebody disables before the patch release where it would have helped.

Source compatibility concerning whether existing code recompiles, against binary compatibility concerning whether existing compiled artifacts still link — with a default argument applied at the call site at compile time, so the old method descriptor disappears and previously compiled bytecode cannot find it; and the failure appearing at runtime on the JVM but at link time on Native

A default argument is resolved at the call site, so it preserves source compatibility and nothing else. If someone can consume you without recompiling — and with a transitive dependency they always can — you owe them binary compatibility.

The fix

Keep the old signature, add an overload, and make the ABI machine-checked.

// 2.4.0 — binary compatible
class SyncClient(private val api: Api) {

    // the ORIGINAL signature, preserved. Old bytecode links against this.
    suspend fun sync(userId: String): SyncResult = sync(userId, 30.seconds)

    // the new capability as a separate overload
    suspend fun sync(userId: String, timeout: Duration): SyncResult { … }
}

Two methods instead of one with a default. Slightly more verbose, and both symbols exist.

Then the tool that makes this checkable — binary-compatibility-validator:

// build.gradle.kts
plugins { alias(libs.plugins.binaryCompatibilityValidator) }

apiValidation {
    // the KLib ABI dump covers the non-JVM targets, which @JvmOverloads does not
    klib { enabled = true }
}
$ ./gradlew apiDump     # once, to record the current ABI
# commits api/sync-client.api  and  api/sync-client.klib.api
$ ./gradlew apiCheck    # in CI, on every PR

And with the original 2.3.1 change applied, that check fails before merge:

$ ./gradlew apiCheck
API check failed for project sync-client.
  --- api/sync-client.api
  +++ build/api/sync-client.api
  - public final suspend fun sync (Ljava/lang/String;Lkotlin/coroutines/Continuation;)…
  + public final suspend fun sync (Ljava/lang/String;Lkotlin/time/Duration;Lkotlin/coroutines/…

A removed method shown as a diff, in review. That is the whole control.

Four decisions worth naming:

The ABI dump is committed. It is a reviewable artifact: a pull request that changes the public ABI shows exactly which symbols changed, and a reviewer can see "removed" versus "added".

klib { enabled = true } so Kotlin/Native and wasm ABIs are covered. Without it the validator checks the JVM only, which is the subset @JvmOverloads already handles.

internal and explicit API mode keep the surface small:

kotlin { explicitApi() }       // every public declaration must be explicitly public

Which forces a decision per declaration, rather than public by default — the same lesson as the exported-iOS-framework surface.

And a documented policy, because the tool reports changes and does not decide them:

## Compatibility policy
- PATCH: no public ABI change. apiCheck must pass with no diff.
- MINOR: additive ABI only (new declarations). Removals and signature changes are forbidden.
- MAJOR: ABI breaks allowed, documented in the migration guide.

Adding a parameter to an existing function is a MAJOR change, or an overload in a MINOR.
Defaults do NOT make it compatible.

How we proved it

That the validator catches the original change:

re-apply the 2.3.1 default-parameter change on a branch
$ ./gradlew apiCheck
  FAILED — 1 removed, 2 added  (sync-client.api)
  FAILED — 1 removed, 2 added  (sync-client.klib.api)
exit 1

Both dumps, both targets, before merge. Nineteen hours of a broken release for one CI task.

That the overload version is genuinely binary compatible, verified by linking rather than by compiling — which is the check that was missing:

# compile a consumer against 2.3.0, then run it against the new artifact WITHOUT recompiling
$ javac -cp sync-client-2.3.0.jar Consumer.java
$ java  -cp sync-client-2.4.0.jar:. Consumer
  sync completed            ← links and runs

# the same, against the yanked 2.3.1
$ java  -cp sync-client-2.3.1.jar:. Consumer
  Exception: NoSuchMethodError: SyncClient.sync(String, Continuation)

That pair of commands is the test. We added it to CI as a real job, because the validator checks the declared ABI and this checks the actual linkage:

- name: Link old consumer bytecode against the new artifact
  run: ./scripts/abi-link-check.sh --old 2.3.0 --new ${{ github.ref_name }}

And the Native half, which fails differently:

$ ./gradlew :consumer:linkDebugExecutableMacosArm64   # consumer klib built against 2.3.0
  2.3.1:  Undefined symbols … kfun:SyncClient#sync(kotlin.String;…)
  2.4.0:  BUILD SUCCESSFUL

The ABI surface, after enabling explicit API mode:

public declarations in the dump
  before:  412
  after:   118     (294 were public by default and should not have been)

Two hundred and ninety-four accidentally-public declarations, which is the same public-by-omission problem as the iOS framework export — and every one of them was an ABI promise we did not know we had made.

One limit: binary-compatibility-validator's KLib support is newer than its JVM support, and the dump format has changed across versions — we had to regenerate the klib.api file on a Kotlin upgrade, which looked like an ABI break and was a format change. Reading the diff carefully, rather than trusting the exit code, is still required.

What this generalizes to

"It compiles" and "it links" are different claims. Source compatibility is about recompiling; binary compatibility is about not recompiling. Every published library owes the second, and testing only the first is the default because building from source is what CI naturally does.

A convenience resolved at the call site helps only code being compiled. Default arguments, inline functions, macros, constant folding. Anything the compiler expands at the usage site cannot retroactively help an artifact already built.

Commit the derived surface so changes appear in review. The ABI dump, like the exported header or the resolved dependency list, turns an invisible change into a diff a reviewer can object to.

The person who must act is often not the person who breaks. A transitive consumer cannot recompile the SDK between them and you. That asymmetry is why a library's compatibility guarantees have to be mechanical rather than advisory.

Keep reading

This is part of 100 Days of Kotlin Multiplatform — the boundaries, the sharp edges, and the things that only show up on the second target. Follow MobileByteSensei for the rest.

One post a week, no digest

New writing when it lands. No roundups, no course pitch, no "10 tips".

Follow by RSS