Post hero imagePost hero image

Aardink 0.4.1 was an Android library with apps depending on it. Aardflex’s Android app is on 0.4.0. Version 0.5.0 had to add two targets, desktop JVM and the browser, without an Android user noticing.

Checks first, then changes

PR 0 moved no code. It added Roborazzi screenshot tests, a small Android app in tools/consumer-smoke/ that depends on the published coordinate, and scripts/verify-consumer.ps1 to build it.

Every later PR had to leave the screenshots unchanged and keep the consumer app building. From PR 3 an ABI dump was added too, and it may only grow.

The checks had a hole. verify-consumer.ps1 printed the dependency tree but never asserted that aardink-android was in it. The claim that Android consumers still resolve the Android artefact was being checked by me squinting at output. The review caught it. The script now exits non-zero if the grep fails.

The riskiest change goes first

The editor used BasicTextField(TextFieldValue) with a VisualTransformation. Both are deprecated. Moving to TextFieldState with InputTransformation and OutputTransformation was the biggest behaviour change in the whole migration.

I did it in PR 1, on the Android-only tree. If something regressed, it could not be blamed on multiplatform.

It paid off straight away. A hardware Ctrl+Z went to the text field’s own undo and skipped the editor’s, so the field and the document drifted apart. Intercepting keys in onPreviewKeyEvent fixed it, and that fix helps Android too.

What stopped the code compiling as common Kotlin

Not much, but each item was a proper job.

  • EditorThemeParser used org.json, which is Android only. I ported it to kotlinx.serialization.json with the same public signature. That is the only new dependency in :editor.
  • LspClient leaned on the JVM for concurrency: ConcurrentHashMap, CopyOnWriteArrayList, AtomicLong, five @Synchronized methods, @Volatile, and Dispatchers.IO as a constructor default. I rewrote it on kotlinx.coroutines.sync.Mutex. I rejected atomicfu because it is a compiler plugin and runtime dependency for every consumer, and kotlin.concurrent.atomics because it is still experimental.
  • StreamLspTransport uses java.io streams, so it moved unchanged into jvmAndAndroidMain. The browser gets WebSocketLspTransport.
  • 23 test files moved from JUnit 5 to kotlin.test. About 70 runBlocking calls became runTest, because wasm has no runBlocking.

The Mutex rewrite broke what @Synchronized did not

Swapping a lock is not a mechanical change. @Synchronized quietly gave me two guarantees, and Mutex gave me neither.

Cancellation. The receive loop ended with finally { onReceiveLoopEnded() }, and that function does lock.withLock { ... }. In a cancelled coroutine, a suspending call in finally throws CancellationException before its body runs. So when a host cancelled the shared scope, closed was never set, pending requests never failed, and the transport never closed.

// before
} finally {
    onReceiveLoopEnded()
}

// after
} finally {
    withContext(NonCancellable) { onReceiveLoopEnded() }
}

sendRequest already had that fix. The loop did not. The existing test cancelled before start, so it never got near the bug.

Ordering. Adding and removing diagnostics listeners fell back to scope.launch when the lock was busy. Two launched coroutines run in any order, so an add then a remove could execute backwards and leak the listener. A late publishDiagnostics could also repopulate state after didClose. @Synchronized gave program order for free.

I fixed that by taking listeners off the lock entirely. They are a @Volatile copy-on-write list, written lock-free, so program order holds again.

The build fought back

CI was red from PR 3 to PR 5. The Android KMP plugin (com.android.kotlin.multiplatform.library) has no test or lint task, so :editor:test and :editor:lint simply failed. Android Lint is gone for the libraries. Tests now run as jvmTest and wasmJsBrowserTest.

The rest, in the order it hurt:

  • The Compose plugin is applied to :languages and :languages-lsp, which have no Compose code. Wasm browser tests need the bundled Skiko runtime to run at all. Same reason for binaries.executable() on library targets (CMP-4906). Odd, but she’ll be right.
  • A buildSrc convention plugin put the Kotlin plugin on the build classpath, and Gradle then rejected each module’s own versioned plugin alias. I deleted buildSrc.
  • Dokka 2’s Markdown output: the task succeeds and writes zero files. I dropped it.
  • The Kotlin daemon ran out of heap at 2 GB linking four wasm test executables. I raised it to 4 GB.