Skip to content

[Native Image] -O1 causes NullPointerException in Compose Runtime ScopeMap.set during state invalidation#14263

Description

@ttldtor

Describe the Issue

A native image built with -O1 throws a NullPointerException while JetBrains Compose Runtime processes a state invalidation.

The same application works correctly:

  • on the JVM;
  • as a native image built with -O0.

The reproducer has a runtime switch that calls Snapshot.sendApplyNotifications(). This makes it possible to demonstrate the failure using the same -O1 executable:

Sample.exe
  -> PASS

Sample.exe notify
  -> NullPointerException at androidx.compose.runtime.collection.ScopeMap.set-impl(ScopeMap.kt:59)

The application does not use terminal handling, FFM, or application JNI/native libraries. The reproducer only uses Compose Runtime and kotlinx.coroutines.

The exception occurs after Compose has observed a MutableState, the state is modified, and pending snapshot changes are delivered to the Recomposer.

Using the latest version of GraalVM can resolve many issues.

GraalVM Version

openjdk version "25.0.4" 2026-07-21
OpenJDK Runtime Environment GraalVM CE 25.2.4+7.1 (build 25.0.4+7-jvmci-25.2-b20)
OpenJDK 64-Bit Server VM GraalVM CE 25.2.4+7.1 (build 25.0.4+7-jvmci-25.2-b20, mixed mode, sharing)

Native Image build output reports:

Builder configuration:
 - Java version: 25.0.4+7, vendor version: GraalVM CE 25.2.4+7.1
 - Graal compiler: optimization level: 1, target machine: x86-64-v3
 - C compiler: cl.exe (microsoft, x64, 19.51.36256)

The same -O1 failure was also reproduced with several other GraalVM 25 builds, including GraalVM CE 25+37.1, GraalVM CE 25.1.3, and Oracle GraalVM 25.0.4.

Operating System and Version

Microsoft Windows [Version 10.0.26300.9032]

Troubleshooting Confirmation

Run Command

Build once with -O1:

.\gradlew.bat clean nativeCompile -PO1 --no-daemon

Run the same executable without snapshot notification:

.\build\native\nativeCompile\Sample.exe

Run the same executable with snapshot notification:

.\build\native\nativeCompile\Sample.exe notify

Expected Behavior

Both invocations of the same native executable should complete without an exception, as they do on the JVM and with Native Image -O0.

Expected output for the notify invocation:

compose: 0
before write
after write
done

Actual Behavior

The -O1 executable succeeds when run without the notify argument:

compose: 0
before write
after write
done

The same executable fails when run with notify:

compose: 0
before write
after write
Exception in thread "main" java.lang.NullPointerException
        at androidx.compose.runtime.collection.ScopeMap.set-impl(ScopeMap.kt:59)
        at androidx.compose.runtime.CompositionImpl.invalidateChecked(Composition.kt:1232)
        at androidx.compose.runtime.CompositionImpl.invalidate(Composition.kt:1183)
        at androidx.compose.runtime.RecomposeScopeImpl.invalidateForResult(RecomposeScopeImpl.kt:257)
        at androidx.compose.runtime.CompositionImpl.addPendingInvalidationsLocked(Composition.kt:897)
        at androidx.compose.runtime.CompositionImpl.addPendingInvalidationsLocked(Composition.kt:913)
        at androidx.compose.runtime.CompositionImpl.drainPendingModificationsLocked(Composition.kt:743)
        at androidx.compose.runtime.CompositionImpl.recordModificationsOf(Composition.kt:877)
        at androidx.compose.runtime.Recomposer.recordComposerModifications(Recomposer.kt:449)
        at androidx.compose.runtime.Recomposer.access$recordComposerModifications(Recomposer.kt:142)
        at androidx.compose.runtime.Recomposer$runRecomposeAndApplyChanges$2.invokeSuspend(Recomposer.kt:570)
        at kotlin.coroutines.jvm.internal.BaseContinuationImpl.resumeWith(ContinuationImpl.kt:33)
        at kotlinx.coroutines.DispatchedTask.run(DispatchedTask.kt:100)
        at kotlinx.coroutines.EventLoopImplBase.processNextEvent(EventLoop.common.kt:263)
        at kotlinx.coroutines.BlockingCoroutine.joinBlocking(Builders.kt:94)
        at kotlinx.coroutines.BuildersKt__BuildersKt.runBlocking(Builders.kt:70)
        at kotlinx.coroutines.BuildersKt.runBlocking(Unknown Source)
        at org.ttldtor.Main.main(Main.kt:30)

The control matrix is:

Runtime no argument notify
JVM PASS PASS
Native Image -O0 PASS PASS
Native Image -O1 PASS NPE

Steps to Reproduce

Create a Gradle Kotlin/JVM project with the following files.

settings.gradle.kts

rootProject.name = "Sample"

build.gradle.kts

Source
import org.jetbrains.kotlin.gradle.dsl.JvmTarget

plugins {
    kotlin("jvm") version "2.2.10"
    application
    id("org.graalvm.buildtools.native") version "1.1.9"
    id("org.jetbrains.kotlin.plugin.compose") version "2.2.10"
}

repositories {
    mavenCentral()
    google()
}

dependencies {
    implementation("org.jetbrains.compose.runtime:runtime:1.8.2")
    implementation("org.jetbrains.kotlinx:kotlinx-coroutines-core:1.10.2")
    implementation("androidx.collection:collection-jvm:1.5.0")
}

kotlin {
    jvmToolchain(24)

    compilerOptions {
        jvmTarget.set(JvmTarget.JVM_24)
    }
}

java {
    sourceCompatibility = JavaVersion.VERSION_24
    targetCompatibility = JavaVersion.VERSION_24
}

application {
    mainClass = "org.ttldtor.Main"
}

graalvmNative {
    metadataRepository {
        enabled.set(false)
    }

    binaries {
        named("main") {
            imageName.set("Sample")
            mainClass.set("org.ttldtor.Main")

            if (providers.gradleProperty("O0").isPresent) {
                buildArgs.add("-O0")
            }

            if (providers.gradleProperty("O1").isPresent) {
                buildArgs.add("-O1")
            }
        }
    }
}

src/main/kotlin/org/ttldtor/Main.kt

Source
@file:JvmName("Main")

package org.ttldtor

import androidx.compose.runtime.AbstractApplier
import androidx.compose.runtime.BroadcastFrameClock
import androidx.compose.runtime.Composition
import androidx.compose.runtime.Recomposer
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.snapshots.Snapshot
import kotlinx.coroutines.CoroutineStart.UNDISPATCHED
import kotlinx.coroutines.launch
import kotlinx.coroutines.runBlocking
import kotlinx.coroutines.yield
import kotlin.jvm.JvmName

private class UnitApplier : AbstractApplier<Unit>(Unit) {
    override fun insertTopDown(index: Int, instance: Unit) {}
    override fun insertBottomUp(index: Int, instance: Unit) {}
    override fun remove(index: Int, count: Int) {}
    override fun move(from: Int, to: Int, count: Int) {}
    override fun onClear() {}
}

private val state = mutableStateOf(0)

fun main(args: Array<String>) {
    val clock = BroadcastFrameClock()

    runBlocking(clock) {
        val recomposer = Recomposer(coroutineContext)
        val composition = Composition(UnitApplier(), recomposer)

        val recomposerJob =
            launch(start = UNDISPATCHED) {
                recomposer.runRecomposeAndApplyChanges()
            }

        composition.setContent {
            println("compose: ${state.value}")
        }

        println("before write")
        state.value = 1
        println("after write")

        if (args.firstOrNull() == "notify") {
            Snapshot.sendApplyNotifications()
        }

        yield()

        recomposer.cancel()
        recomposer.join()
        recomposerJob.join()

        println("done")
    }
}

A JDK 24 toolchain is used to compile the Kotlin 2.2.10 sources to JVM 24 bytecode. GraalVM 25.2.4 is used by the Native Image Gradle task.

Reproduce the failure:

.\gradlew.bat clean nativeCompile -PO1 --no-daemon
.\build\native\nativeCompile\Sample.exe
.\build\native\nativeCompile\Sample.exe notify

The first invocation passes; the second invocation of the same executable throws the NPE above.

Control with -O0:

.\gradlew.bat clean nativeCompile -PO0 --no-daemon
.\build\native\nativeCompile\Sample.exe
.\build\native\nativeCompile\Sample.exe notify

Both invocations pass.

Control on the JVM:

.\gradlew.bat clean run --no-daemon
.\gradlew.bat run --args=notify --no-daemon

Both invocations pass.

Additional Context

The reproducer was minimized from an application that originally used Mosaic, but Mosaic is not required to reproduce the issue and is not present in the code above.

The failure requires Compose to observe mutable state and then process the corresponding snapshot modification. Merely creating/writing MutableState without an observed composition does not reproduce it.

Calling Snapshot.sendApplyNotifications() is a runtime trigger, not necessarily the location of the miscompilation. The failing path is:

Snapshot.sendApplyNotifications()
    -> Recomposer.recordComposerModifications()
    -> CompositionImpl.recordModificationsOf()
    -> CompositionImpl.invalidateChecked()
    -> ScopeMap.set()
    -> NullPointerException

During minimization I also tried disabling several individual Native Image optimizations at -O1, including trivial AOT inlining, regular and early read elimination, partial escape analysis, conditional elimination, and inlining. These experiments did not make the -O1 failure disappear.

The ScopeMap code in Compose Runtime is a Kotlin value class wrapping an AndroidX MutableScatterMap. A standalone reduced test using a similar value class and MutableScatterMap does not reproduce the issue, so the failure appears to require the actual Compose invalidation path rather than the collection/value-class construct alone.

Run-Time Log Output and Error Messages

The minimal relevant output from GraalVM CE 25.2.4+7.1 is:

Builder configuration:
 - Java version: 25.0.4+7, vendor version: GraalVM CE 25.2.4+7.1
 - Graal compiler: optimization level: 1, target machine: x86-64-v3
 - C compiler: cl.exe (microsoft, x64, 19.51.36256)

Then, for the same generated Sample.exe:

> .\build\native\nativeCompile\Sample.exe
compose: 0
before write
after write
done

> .\build\native\nativeCompile\Sample.exe notify
compose: 0
before write
after write
Exception in thread "main" java.lang.NullPointerException
        at androidx.compose.runtime.collection.ScopeMap.set-impl(ScopeMap.kt:59)
        at androidx.compose.runtime.CompositionImpl.invalidateChecked(Composition.kt:1232)
        at androidx.compose.runtime.CompositionImpl.invalidate(Composition.kt:1183)
        at androidx.compose.runtime.RecomposeScopeImpl.invalidateForResult(RecomposeScopeImpl.kt:257)
        at androidx.compose.runtime.CompositionImpl.addPendingInvalidationsLocked(Composition.kt:897)
        at androidx.compose.runtime.CompositionImpl.addPendingInvalidationsLocked(Composition.kt:913)
        at androidx.compose.runtime.CompositionImpl.drainPendingModificationsLocked(Composition.kt:743)
        at androidx.compose.runtime.CompositionImpl.recordModificationsOf(Composition.kt:877)
        at androidx.compose.runtime.Recomposer.recordComposerModifications(Recomposer.kt:449)
        at androidx.compose.runtime.Recomposer.access$recordComposerModifications(Recomposer.kt:142)
        at androidx.compose.runtime.Recomposer$runRecomposeAndApplyChanges$2.invokeSuspend(Recomposer.kt:570)
        at kotlin.coroutines.jvm.internal.BaseContinuationImpl.resumeWith(ContinuationImpl.kt:33)
        at kotlinx.coroutines.DispatchedTask.run(DispatchedTask.kt:100)
        at kotlinx.coroutines.EventLoopImplBase.processNextEvent(EventLoop.common.kt:263)
        at kotlinx.coroutines.BlockingCoroutine.joinBlocking(Builders.kt:94)
        at kotlinx.coroutines.BuildersKt__BuildersKt.runBlocking(Builders.kt:70)
        at kotlinx.coroutines.BuildersKt.runBlocking(Unknown Source)
        at org.ttldtor.Main.main(Main.kt:30)

Metadata

Metadata

Assignees

No one assigned

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions