Environment setup
This course uses LiteRT-LM v0.18.0 (released 6 Oct 2026). Check the version you ship, because several issues below changed between releases.
- Android: Android Studio, a Kotlin app module, and a real arm64 phone. Add the AAR from Google Maven:
implementation("com.google.ai.edge.litertlm:litertlm-android:0.18.0"). - iOS: Xcode 27, an iOS 27 device. Add the Swift package
https://github.com/google-ai-edge/LiteRT-LMand link theLiteRTLMlibrary to your app target. No Core AI framework or Metal Toolchain is needed for LiteRT-LM itself. - Mac tools: Python 3.11+ through
uvfor the parity report script and thelitert-lmCLI.
curl -LsSf https://astral.sh/uv/install.sh | sh
mkdir litert-ship && cd litert-ship
uv init --python 3.11
uv venv
# PyTorch is only needed if you also convert models in this project.
# On Apple silicon the default wheel includes MPS; elsewhere it runs on CPU.
uv add torch
uv add litert-lm
uv run litert-lm --help
The crash
Issue #3771 (opened 28 Sep 2026, still open) describes a SIGSEGV that came from a real app through Google Play. The reporter traced it in the source:
- If a
Conversationis closed whileSendMessageAsyncis still in prefill, the native destructor resetssession_first and then waits. - The prefill finishes during that wait. Its callback then calls
session_->RunDecodeAsync(...), butsession_is already null. - In Kotlin,
Conversation.close()callsnativeDeleteConversationdirectly and does not cancel anything. TheFlowfromsendMessageAsyncends with an emptyawaitClose {}, so cancelling the collector leaves the native task running.
So the natural code, "cancel the coroutine and call close() in finally", opens the crash window whenever the cancel lands during prefill. A Google team member wrote that they reproduced it locally. We checked Conversation.kt on main on 15 Oct 2026 and it still matches that description.
Android: use the callback API and track idle
The callback form of sendMessageAsync tells you when native work ends through onDone or onError. Use that signal to decide when close() is safe. This wrapper keeps one CompletableDeferred per request.
import com.google.ai.edge.litertlm.Conversation
import com.google.ai.edge.litertlm.Engine
import com.google.ai.edge.litertlm.Message
import com.google.ai.edge.litertlm.MessageCallback
import kotlinx.coroutines.CompletableDeferred
import kotlinx.coroutines.withTimeoutOrNull
class SafeConversation(private val conversation: Conversation) {
@Volatile private var idle: CompletableDeferred<Unit> = CompletableDeferred(Unit)
fun send(text: String, onText: (String) -> Unit) {
val done = CompletableDeferred<Unit>()
idle = done
conversation.sendMessageAsync(Message.of(text), object : MessageCallback {
override fun onMessage(message: Message) = onText(message.toString())
override fun onDone() { done.complete(Unit) }
override fun onError(throwable: Throwable) { done.complete(Unit) }
})
}
/** Stop button: ask native code to stop. Do not close here. */
fun stop() {
if (!idle.isCompleted) conversation.cancelProcess()
}
/** Screen close or unload: cancel, wait for idle, then close. */
suspend fun closeSafely(timeoutMs: Long = 10_000): Boolean {
stop()
val finished = withTimeoutOrNull(timeoutMs) { idle.await() } != null
if (!finished) {
// Do not close while native work may still run. Keep the object and try later.
android.util.Log.w("SafeConversation", "not idle after $timeoutMs ms, close skipped")
return false
}
conversation.close()
return true
}
}
suspend fun unloadModel(chat: SafeConversation, engine: Engine) {
if (chat.closeSafely()) engine.close() // Conversation first, then Engine
}
Check the exact Message factory name in your LiteRT-LM version. On main, Conversation also has sendMessageAsync(text: String, ...) overloads, so you can pass a string.
iOS: cancel, then wait for the stream to end
In Swift, Conversation.sendMessageStream(_:) returns an AsyncThrowingStream, and Conversation.cancel() asks native code to stop. The Swift Conversation frees native memory in deinit, so "close" means dropping your last reference. Only do that after the stream loop has ended.
import LiteRTLM
actor ChatSession {
private var conversation: Conversation?
private var streamTask: Task<Void, Never>?
init(conversation: Conversation) { self.conversation = conversation }
func send(_ text: String, onText: @escaping @Sendable (String) -> Void) {
guard let conversation else { return }
streamTask = Task {
do {
for try await chunk in conversation.sendMessageStream(Message(text)) {
if case .text(let piece)? = chunk.contents.first { onText(piece) }
}
} catch {
// Cancelled or failed. Either way the stream has ended.
}
}
}
func stop() {
try? conversation?.cancel()
}
/// Cancel, wait for the stream to finish, then drop the Conversation.
func close() async {
stop()
await streamTask?.value
streamTask = nil
conversation = nil // deinit frees the native conversation now that it is idle
}
}
Unload under memory pressure
The memory side is covered in Load one model at a time. The order is the new part here: when onTrimMemory or a memory warning arrives, call closeSafely() (Android) or close() (iOS) first, and close or drop the Engine only after that returns.
Key concepts
- #3771: closing a Conversation during prefill can crash because the prefill callback uses a session that was already reset.
- Cancelling a Kotlin coroutine does not stop native work.
cancelProcess()does. onDoneoronError(Kotlin) and the end of the stream loop (Swift) tell you the Conversation is idle.- Close the Conversation before the Engine.
Takeaways
- Wrap every Conversation in a small class that knows when it is idle.
- If idle does not come in time, skip the close and try later.
- Run a Stop-then-leave test 100 times on a device before each release.