← All courses ← Course home

Lesson 01 of 05

Close a Conversation safely

A Stop button plus a screen close can crash LiteRT-LM in native code. Cancel the work, wait until the Conversation is idle, then close it. Unload under memory pressure in the same order.

Agent brief (llms.md)

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-LM and link the LiteRTLM library to your app target. No Core AI framework or Metal Toolchain is needed for LiteRT-LM itself.
  • Mac tools: Python 3.11+ through uv for the parity report script and the litert-lm CLI.
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:

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.

1cancelProcess()
2Wait for onDone or onError
3close() Conversation
4close() Engine if unloading

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.

Why the timeout returns false. The reporter of #3771 points to #2422, where a cancelled session may not drain promptly. If idle never comes, closing anyway brings the crash back. Keeping the object until the next try costs memory, not a crash.

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.
  • onDone or onError (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.