Engineering

On desktop your app has windows, and nothing closes them for you

A window holds a composition, a scope and native handles. Nothing releases them for you.

MobileByteSensei 10 min read
kmpcomposedesktop

TL;DR

  • A desktop build whose memory grew 38 MB per window open-and-close and whose process refused to exit after the last window was closed. Twenty cycles: 112 MB → 871 MB.
  • A Window is not an Activity. Nothing in the platform owns its composition, its coroutine scope or its native handle, because on desktop your main is the owner.
  • The fix is three changes of ownership — key(id) so a window's identity is its document rather than its position in a list, an explicitly-owned scope that flushes before it cancels, and exitApplication driven by a registry rather than by the composition.
  • What reading on buys: why rememberCoroutineScope is the wrong tool here, and the one measurement that distinguishes a leaked composition from a leaked native handle.

The case this post is about

A document editor with a multi-window menu — File ▸ New Window — written the way the Android version was:

// desktopMain/kotlin/Main.kt
fun main() = application {
    var openDocs by remember { mutableStateOf(listOf(DocId.new())) }

    openDocs.forEach { id ->
        Window(
            onCloseRequest = { openDocs = openDocs - id },
            title = "Editor — $id",
        ) {
            // Each window builds its own view model, the way an Activity would
            val vm = remember { DocumentViewModel(id, repo) }
            DocumentScreen(vm, onNewWindow = { openDocs = openDocs + DocId.new() })
        }
    }
}
// commonMain/kotlin/DocumentViewModel.kt
class DocumentViewModel(private val id: DocId, private val repo: DocRepo) {

    private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Default)
    val state = MutableStateFlow(DocState.Loading)

    init {
        scope.launch { repo.watch(id).collect { state.value = DocState.Ready(it) } }
        scope.launch { while (true) { delay(30.seconds); repo.autosave(id, state.value) } }
    }
}

That reads correctly, and on Android it would be. onCloseRequest removes the id, the Window leaves the composition, and the remembered view model goes with it.

The symptom

Two things, and the first one hid the second for a week.

Memory grew per window cycle and never came back. Opening and closing a window twenty times, sampled from the JVM itself after a forced collection:

$ jcmd <pid> GC.run && jcmd <pid> VM.native_memory summary | grep -A2 'Java Heap'

cycle  1   heap  112 MB
cycle  5   heap  263 MB
cycle 10   heap  457 MB
cycle 20   heap  871 MB      # after an explicit GC — not garbage, reachable

And the process would not exit. Closing the last window left the app running with no visible UI — no window, no dock icon state change, and jps still listing it:

$ jps -l
48213 com.example.editor.MainKt

Which on Android has no equivalent symptom at all, because the system kills the process. Here the process is yours, and application { } exits when its composition ends — which it had not, because something was still holding on.

A live-instance histogram showing twenty retained window objects for one visible window, holding six hundred and forty-two megabytes while the view models they contained accounted for seven — so the first fix had been aimed at one percent of the retained bytes

Why the obvious fix failed

The first hypothesis was the view model's scope, and the fix was the obvious one:

val vm = remember { DocumentViewModel(id, repo) }
DisposableEffect(Unit) { onDispose { vm.close() } }       // vm.close() cancels the scope

Memory per cycle: 38 MB → 31 MB. Better, and still unbounded — so something else was holding the window. The 7 MB it did recover was the autosave coroutine, which is real and was not the bulk.

The second hypothesis was the openDocs list holding stale ids. It was not; the list was correct after every close:

cycle 20:  openDocs.size == 0, heap 871 MB

An empty list and 871 MB of reachable heap is the point where I stopped guessing and took a heap dump. The dominator was not a view model and not a composition:

$ jcmd <pid> GC.heap_dump /tmp/e.hprof
# Eclipse MAT, dominator tree, top entry:

androidx.compose.ui.awt.ComposeWindow            20 instances   612 MiB retained
#   (MAT reports MiB and labels it MB; the same bytes appear as 642 MB decimal in the
#    histogram further down. 642,139,904 B = 612.4 MiB — one quantity, two conventions.)
  └─ javax.swing.JFrame$AccessibleJFrame
     └─ java.awt.Frame.framesList                 ← static, holds every frame ever created

java.awt.Frame keeps a static list of all frames, and nothing had removed ours from it. That is not a Compose detail; it is AWT, two layers below, and no amount of disposing the composition reaches it.

The actual mechanism

A desktop window is three resources with three different owners, and only one of them is Compose's:

The composition. Owned by the Window composable's place in the application composition. Leaving the composition disposes it, and this part does work as expected.

Any scope you created inside it. Owned by nobody. CoroutineScope(...) in a class constructor has no lifecycle; it is cancelled when something calls cancel(). On Android viewModelScope is cancelled by onCleared(), which the ViewModelStore calls — and on desktop there is no store and no onCleared.

The native window. Owned by the toolkit. A ComposeWindow wraps a JFrame, AWT registers every Frame in a static list, and it is removed on dispose() — a method Compose calls for you only if the window leaves the composition through the composition, which onCloseRequest removing an id does do. Ours did not, for a reason that took the heap dump to see: openDocs was a remember inside application, and forEach over it re-created the Window call sites positionally. Removing an id from the middle of the list shifted every later window by one position, so Compose treated them as content changes to existing windows rather than as one window leaving — exactly the lazy-list identity problem, one layer up.

So the window that "closed" was actually the last one in the list leaving the composition, while the one the user closed kept its frame and had someone else's document rendered into it. Twenty cycles, twenty frames retained, and the visible UI looked right the whole time because the content had shifted along with the identity.

And the second owner compounds it: each retained frame holds the composition that was rendered into it, which holds the view model, which holds the scope — so one missing identity key retained all three resource classes at once. That is why the DisposableEffect fix recovered 7 MB and not 612: it disposed the scope of a view model whose window was still alive and still holding it.

Three resources a desktop window holds and who owns each: the composition owned by its place in the application composition, a self-created coroutine scope owned by nobody, and the native frame owned by the toolkit and registered in a static list

The fix

Key the windows by identity, own the scope explicitly, and drive application exit from a registry rather than from the composition.

// desktopMain/kotlin/Main.kt
fun main() = application {
    // A LIST keyed positionally is the bug. A map keyed by id makes each Window's identity
    // its document, so removing one from the middle removes THAT window rather than shifting
    // every later one into a new position.
    val openDocs = remember { mutableStateMapOf<DocId, Unit>() }
        .also { if (it.isEmpty()) it[DocId.new()] = Unit }

    openDocs.keys.toList().forEach { id ->
        // key() gives this Window call site a stable identity across recompositions, so a
        // removal disposes this subtree instead of renaming its neighbours.
        key(id) {
            DocumentWindow(
                id = id,
                onClose = { openDocs.remove(id) },
                onNewWindow = { openDocs[DocId.new()] = Unit },
            )
        }
    }

    // Exit is OURS to decide on desktop. Driving it off the registry rather than off the
    // composition means a zero-window state exits even if a frame is still being torn down.
    LaunchedEffect(openDocs.size) {
        if (openDocs.isEmpty()) exitApplication()
    }
}

@Composable
private fun DocumentWindow(id: DocId, onClose: () -> Unit, onNewWindow: () -> Unit) {
    Window(onCloseRequest = onClose, title = "Editor — $id") {

        // rememberCoroutineScope() would be WRONG here: it is cancelled when the composition
        // leaves, which is right for UI work and wrong for a 30-second autosave that must
        // complete its in-flight write. An explicitly-owned scope with a declared teardown
        // lets the close path await the final save.
        val vm = remember(id) { DocumentViewModel(id, repo) }

        DisposableEffect(id) {
            onDispose {
                // Ordered: flush first, THEN cancel. Cancelling first discards the write,
                // which is a data-loss bug that only appears on a fast close.
                vm.flushBlocking(timeout = 2.seconds)
                vm.close()
            }
        }

        DocumentScreen(vm, onNewWindow = onNewWindow)
    }
}

And the view model's scope, with the teardown it now owes:

// commonMain/kotlin/DocumentViewModel.kt
class DocumentViewModel(private val id: DocId, private val repo: DocRepo) {

    private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Default)
    val state = MutableStateFlow<DocState>(DocState.Loading)

    init {
        scope.launch { repo.watch(id).collect { state.value = DocState.Ready(it) } }
        scope.launch { while (isActive) { delay(AUTOSAVE); save() } }
    }

    /** Blocking by design: a window close is synchronous and the write must land first. */
    fun flushBlocking(timeout: Duration) = runBlocking {
        withTimeoutOrNull(timeout) { save() }
            ?: logger.warn("autosave for $id did not complete in $timeout — window closed anyway")
    }

    fun close() = scope.cancel()

    private suspend fun save() = (state.value as? DocState.Ready)?.let { repo.autosave(id, it) }

    companion object { val AUTOSAVE = 30.seconds }
}

And the output it produced, the same twenty-cycle run:

$ jcmd <pid> GC.run && jcmd <pid> VM.native_memory summary | grep 'Java Heap'

cycle  1   heap  109 MB
cycle  5   heap  114 MB
cycle 10   heap  111 MB
cycle 20   heap  113 MB

$ # close the last window
$ jps -l | grep -c editor
0

Flat across twenty cycles, and the process exits.

How we proved it

That the retained frames were the dominator and not the view models — the measurement that redirected the whole investigation:

$ jmap -histo:live <pid> | head -5
 num     #instances         #bytes  class name
   1:            20      642139904  androidx.compose.ui.awt.ComposeWindow
   2:            20        7340032  com.example.DocumentViewModel

Twenty windows for one visible window, and the view models were 1% of the bytes attributed to the two classes. The first fix had been aimed at the 1%. (The retained figure is the dominator tree's, above — jmap -histo reports per-class totals, not retained-set sizes, so it is the instance count and the ratio that this corroborates.)

That the cause was positional identity and not disposal. The test is to close a window from the end of the list rather than the middle, which positional identity handles correctly:

close the LAST window,   20 cycles   heap 111 MB → 118 MB    (frames: 1)
close the FIRST window,  20 cycles   heap 112 MB → 871 MB    (frames: 20)

Same code, same count of opens and closes, and the only difference is which one the user closed. That asymmetry is what a disposal bug cannot produce and an identity bug must.

That key(id) is the load-bearing change rather than the map, which is the one I expected to matter:

list + key(id)        20 cycles, close-first:  heap 113 MB   frames 1
map, no key(id)       20 cycles, close-first:  heap 844 MB   frames 19
map + key(id)         20 cycles, close-first:  heap 113 MB   frames 1

The map is tidier and did nothing on its own. mutableStateMapOf changes how you store the ids; key() changes what Compose believes an item is, and only the second one was the bug.

That the flush-before-cancel ordering prevents real data loss. A deliberately-broken fixture with the two calls swapped, run before the fix was trusted:

$ ./gradlew :desktop:test --tests '*CloseDuringAutosave*'
FAILED: expected document content "…final edit" but repo held "…previous edit"
        (cancel() preceded flushBlocking(); the in-flight write was discarded)

The limit worth naming. flushBlocking blocks the AWT event thread for up to two seconds, and on a slow disk a user closing a window will see the UI hang. That is a deliberate trade — losing the last edit is worse — but it is a trade, not a solution, and the honest version would move the write off the close path entirely by making autosave synchronous on every edit. We did not do that, because the write amplification on a large document was worse than the hang.

And what I cannot tell you: whether the 38 MB per cycle generalises. It is a function of this document model's size, and a window rendering a small form would retain correspondingly less. What transfers is the shape — a retained ComposeWindow holds everything composed into it — and the measurement that finds it is a histogram of live instances, not a heap total.

Twenty open-and-close cycles measured four ways, showing that closing the last window leaks nothing while closing the first leaks every frame, and that the identity key rather than the map is what fixes it

What this generalizes to

On Android, three resource lifetimes are managed for you by components whose whole job is managing them, and the habit that produces is to treat lifecycle as ambient. Desktop removes that: main is yours, the window is a real object with a real owner, and nothing calls onCleared. The reliable question when porting a screen is not "what did the Activity do" but "which object owns this, now that no framework does" — and the answer is usually that you have to name an owner explicitly, which is also the version that is easier to reason about on every target.


Keep reading

One post a week on shipping mobile software. No digests, no course pitch. Subscribe.

One post a week, no digest

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

Follow by RSS