From 64c97739b218a9bbd8a73559ffec1d1ee0f78dbb Mon Sep 17 00:00:00 2001 From: BREW <81044362+b-re-w@users.noreply.github.com> Date: Sun, 18 Oct 2026 11:49:58 +0900 Subject: [PATCH 1/2] Feat: Refuse Liberica NIK for AWT-free macOS images, and keep main on the first thread there An AWT-free macOS image needs main on the process's first thread, because the AppKit window takes its calls there. Liberica NIK 25.0.4.1 runs main on a new thread on macOS whatever -H:-RunMainInNewThread says (run 39084384117), and the image hangs at its first window call. packageNativeImage now reads the GraalVM's release file before building and stops with the reason and the fix (upstream GraalVM 25) when its IMPLEMENTOR is BellSoft. It also gives -H:-RunMainInNewThread itself, unlocked, for every AWT-free macOS image, so the samples no longer have to. Documented in extended/README.md, README_ko.md and extended/native-image/README.md. NativeImageMainThreadTest covers the detection. --- extended/README.md | 11 +++-- extended/README_ko.md | 11 +++-- extended/native-image/README.md | 4 +- .../hello-awtfree-link/build.gradle.kts | 3 -- .../hello-awtfree/build.gradle.kts | 5 --- .../internal/NativeImageMainThread.kt | 36 +++++++++++++++++ .../tasks/AbstractNativeImageTask.kt | 9 +++++ .../tests/unit/NativeImageMainThreadTest.kt | 40 +++++++++++++++++++ 8 files changed, 104 insertions(+), 15 deletions(-) create mode 100644 gradle-plugins/compose/src/main/kotlin/org/jetbrains/compose/desktop/application/internal/NativeImageMainThread.kt create mode 100644 gradle-plugins/compose/src/test/kotlin/org/jetbrains/compose/test/tests/unit/NativeImageMainThreadTest.kt diff --git a/extended/README.md b/extended/README.md index 58ea90aeca..6274055bea 100644 --- a/extended/README.md +++ b/extended/README.md @@ -234,9 +234,14 @@ per platform, with what was measured: ### Requirements -- **GraalVM: Liberica NIK 25 Full.** The image links the JDK's AWT statically, and only a - distribution that ships the JDK's static archives can do that. The task stops with that - reason when they are missing. Measured with NIK 25.0.4.1. +- **GraalVM, by windowing.** With `windowing = Awt`, Liberica NIK 25 Full: the image links the + JDK's AWT statically, and only a distribution that ships the JDK's static archives can do + that. The task stops with that reason when they are missing. Measured with NIK 25.0.4.1. + With `windowing = AwtFree` on macOS, upstream GraalVM 25 (`graalvm-community-jdk-25`): the + AppKit window takes its calls on the process's first thread, so `main` has to run there, + and Liberica NIK runs `main` on a new thread on macOS whatever `-H:-RunMainInNewThread` says. + The image it builds would hang at its first window call, so the task refuses NIK there + before building, and says which GraalVM to use. On Linux either one works. - **The static skiko archive**, from `extended/skiko/build-skiko-static-jvm.sh` in compose-multiplatform-core-extended, built on the same system. A single executable cannot load Skia from a file. diff --git a/extended/README_ko.md b/extended/README_ko.md index 0f675dba3c..be2527855a 100644 --- a/extended/README_ko.md +++ b/extended/README_ko.md @@ -147,9 +147,14 @@ Kotlin/Native `.app` 은 `signDistributableNative...` 가 서명합니다. `nati ### 요구 사항 -- **GraalVM: Liberica NIK 25 Full.** 이미지는 JDK 의 AWT 를 정적으로 링크하는데, 그럴 수 있는 것은 - JDK 의 정적 아카이브를 함께 배포하는 배포판뿐입니다. 아카이브가 없으면 태스크가 이 이유를 말하고 - 멈춥니다. NIK 25.0.4.1 로 측정했습니다. +- **GraalVM 은 windowing 에 따라 다릅니다.** `windowing = Awt` 는 Liberica NIK 25 Full 이 필요합니다. + 이미지는 JDK 의 AWT 를 정적으로 링크하는데, 그럴 수 있는 것은 JDK 의 정적 아카이브를 함께 배포하는 + 배포판뿐입니다. 아카이브가 없으면 태스크가 이 이유를 말하고 멈춥니다. NIK 25.0.4.1 로 측정했습니다. + macOS 의 `windowing = AwtFree` 는 업스트림 GraalVM 25 (`graalvm-community-jdk-25`) 가 필요합니다. + AppKit 창은 프로세스의 첫 스레드에서 호출을 받으므로 `main` 이 그 스레드에서 돌아야 하는데, Liberica + NIK 는 macOS 에서 `-H:-RunMainInNewThread` 와 상관없이 `main` 을 새 스레드에서 돌립니다. 그렇게 빌드한 + 이미지는 첫 창 호출에서 멈추므로, 태스크는 빌드 전에 NIK 를 거부하고 어떤 GraalVM 을 쓰면 되는지 + 알려 줍니다. Linux 에서는 둘 다 됩니다. - **정적 skiko 아카이브.** compose-multiplatform-core-extended 의 `extended/skiko/build-skiko-static-jvm.sh` 로 같은 시스템에서 빌드합니다. 단일 실행 파일은 Skia 를 파일에서 불러올 수 없습니다. diff --git a/extended/native-image/README.md b/extended/native-image/README.md index 09141a6a99..570b4a2edb 100644 --- a/extended/native-image/README.md +++ b/extended/native-image/README.md @@ -134,7 +134,9 @@ JDK's static AWT libraries. An AWT-free application on macOS needs a GraalVM tha the process's first thread when told `-H:-RunMainInNewThread`: the AppKit window hands its calls to that thread, which has to be serving them. Liberica NIK 25.0.4.1 runs `main` on a new thread on macOS whatever the option says, and the first call that crosses waits forever; upstream -GraalVM honours it. The gate builds the AWT-free samples on macOS with upstream GraalVM 25.0.1, +GraalVM honours it. `packageNativeImage` gives the option itself for an AWT-free macOS image, and +refuses to start with Liberica NIK there (it reads `IMPLEMENTOR` in the GraalVM's `release` +file), naming the GraalVM to use instead. The gate builds the AWT-free samples on macOS with upstream GraalVM 25.0.1, and `hello-awtfree` exits with a message rather than hanging when `main` is on another thread. ## The one-executable gate diff --git a/extended/native-image/hello-awtfree-link/build.gradle.kts b/extended/native-image/hello-awtfree-link/build.gradle.kts index 29029ee488..d3f59bdf99 100644 --- a/extended/native-image/hello-awtfree-link/build.gradle.kts +++ b/extended/native-image/hello-awtfree-link/build.gradle.kts @@ -33,9 +33,6 @@ compose.desktop.application { // CORE_EXTENDED: a checkout of compose-multiplatform-core-extended; SKIKO_STATIC: the // build-skiko-static-jvm.sh --no-jawt output. providers.environmentVariable("CORE_EXTENDED").orNull?.let { windowSourcesDirectory.set(file(it)) } - // AppKit calls have to come from the process's first thread; GraalVM's launcher runs - // main on a new one on macOS unless told not to. - if (macos) buildArgs.add("-H:-RunMainInNewThread") providers.environmentVariable("SKIKO_STATIC").orNull?.let { skikoStaticDirectory.set(file(it)) } } } diff --git a/extended/native-image/hello-awtfree/build.gradle.kts b/extended/native-image/hello-awtfree/build.gradle.kts index 5c257a0462..304870d7b9 100644 --- a/extended/native-image/hello-awtfree/build.gradle.kts +++ b/extended/native-image/hello-awtfree/build.gradle.kts @@ -40,11 +40,6 @@ compose.desktop.application { // CORE_EXTENDED: a checkout of compose-multiplatform-core-extended; SKIKO_STATIC: the // build-skiko-static-jvm.sh --no-jawt output. providers.environmentVariable("CORE_EXTENDED").orNull?.let { windowSourcesDirectory.set(file(it)) } - // AppKit calls have to come from the process's first thread; GraalVM's launcher runs - // main on a new one on macOS unless told not to. - // The option is experimental, so it is unlocked first: the run that hung (39082357989) had - // main in JavaMainWrapper.doRunInNewThread with the option given while locked. - if (macos) buildArgs.addAll("-H:+UnlockExperimentalVMOptions", "-H:-RunMainInNewThread") // The sample's text is Korean; the image keeps the locale data it needs. buildArgs.add("-H:IncludeLocales=en,ko") // NATIVE_IMAGE_CALL_TREE set: the analysis call tree is written to the reports folder, to diff --git a/gradle-plugins/compose/src/main/kotlin/org/jetbrains/compose/desktop/application/internal/NativeImageMainThread.kt b/gradle-plugins/compose/src/main/kotlin/org/jetbrains/compose/desktop/application/internal/NativeImageMainThread.kt new file mode 100644 index 0000000000..cc374048e6 --- /dev/null +++ b/gradle-plugins/compose/src/main/kotlin/org/jetbrains/compose/desktop/application/internal/NativeImageMainThread.kt @@ -0,0 +1,36 @@ +/* + * Copyright 2026 thisisthepy and respective authors and developers. + * Use of this source code is governed by the Apache 2.0 license that can be found in the LICENSE.txt file. + */ + +package org.jetbrains.compose.desktop.application.internal + +/** + * Which thread `main` runs on in an AWT-free native image on macOS. + * + * The AppKit window takes its calls on the process's first thread, and hands a call made on any + * other thread to that one and waits for it. So `main` has to run on the first thread, which is + * what `-H:-RunMainInNewThread` asks for. Liberica NIK 25 runs `main` on a new thread on macOS + * whatever that option says, and keeps the first thread waiting for `main` to end; the first + * window call that crosses then waits forever. Upstream GraalVM honours the option. + */ +internal object NativeImageMainThread { + /** The options that keep `main` on the first thread, to be given while experimental options are unlocked. */ + val options: List = listOf("-H:-RunMainInNewThread") + + /** + * Why the GraalVM described by [release], the text of its `release` file, cannot build an + * AWT-free macOS application, or null when it can. + */ + fun problem(release: String): String? { + val implementor = Regex("""^IMPLEMENTOR="?([^"\n]*)"?""", RegexOption.MULTILINE).find(release)?.groupValues?.get(1).orEmpty() + val liberica = implementor.contains("BellSoft", ignoreCase = true) || + release.contains("Liberica", ignoreCase = true) + if (!liberica) return null + return "This GraalVM is Liberica NIK (IMPLEMENTOR=\"$implementor\"). On macOS it runs main on a new thread " + + "whatever -H:-RunMainInNewThread says, and an AWT-free window then waits forever on its first call, " + + "which has to run on the process's first thread. Build windowing = AwtFree on macOS with upstream " + + "GraalVM 25 (graalvm-community-jdk-25), set as nativeImage.graalvmHome or GRAALVM_HOME. " + + "Liberica NIK Full is what windowing = Awt needs, for the JDK's static AWT libraries." + } +} diff --git a/gradle-plugins/compose/src/main/kotlin/org/jetbrains/compose/desktop/application/tasks/AbstractNativeImageTask.kt b/gradle-plugins/compose/src/main/kotlin/org/jetbrains/compose/desktop/application/tasks/AbstractNativeImageTask.kt index cfa0181db9..fde65285ce 100644 --- a/gradle-plugins/compose/src/main/kotlin/org/jetbrains/compose/desktop/application/tasks/AbstractNativeImageTask.kt +++ b/gradle-plugins/compose/src/main/kotlin/org/jetbrains/compose/desktop/application/tasks/AbstractNativeImageTask.kt @@ -24,6 +24,7 @@ import org.gradle.api.tasks.PathSensitivity import org.gradle.api.tasks.TaskAction import org.gradle.work.DisableCachingByDefault import org.jetbrains.compose.desktop.application.dsl.ApplicationWindowing +import org.jetbrains.compose.desktop.application.internal.NativeImageMainThread import org.jetbrains.compose.desktop.application.internal.NativeImageNoAwt import org.jetbrains.compose.desktop.tasks.AbstractComposeDesktopTask import org.jetbrains.compose.internal.utils.OS @@ -123,6 +124,12 @@ abstract class AbstractNativeImageTask : AbstractComposeDesktopTask() { } val staticJdk = graalvm.resolve(platform.staticJdkDirectory) val awt = windowing.get() == ApplicationWindowing.Awt + // Before anything is built: on macOS an AWT-free image needs main on the first thread, + // which Liberica NIK does not give, and the image it builds hangs instead of failing. + if (platform == Platform.MacosArm64 && !awt) { + val release = graalvm.resolve("release") + NativeImageMainThread.problem(if (release.isFile) release.readText() else "")?.let { throw GradleException(it) } + } for (archive in if (awt) platform.staticJdkArchives else emptyList()) { if (!staticJdk.resolve(archive).isFile) { throw GradleException( @@ -583,6 +590,8 @@ abstract class AbstractNativeImageTask : AbstractComposeDesktopTask() { // One option per type: the option does not split its value at commas, so a joined // list names one type that does not exist and forbids nothing. NativeImageNoAwt.forbiddenTypes.map { "-H:ReportAnalysisForbiddenType=$it" } + + // On macOS the AppKit window takes its calls on the process's first thread, so main runs there. + (if (currentOS == OS.MacOS) NativeImageMainThread.options else emptyList()) + "-H:-UnlockExperimentalVMOptions" } diff --git a/gradle-plugins/compose/src/test/kotlin/org/jetbrains/compose/test/tests/unit/NativeImageMainThreadTest.kt b/gradle-plugins/compose/src/test/kotlin/org/jetbrains/compose/test/tests/unit/NativeImageMainThreadTest.kt new file mode 100644 index 0000000000..47359c1773 --- /dev/null +++ b/gradle-plugins/compose/src/test/kotlin/org/jetbrains/compose/test/tests/unit/NativeImageMainThreadTest.kt @@ -0,0 +1,40 @@ +/* + * Copyright 2026 thisisthepy and respective authors and developers. + * Use of this source code is governed by the Apache 2.0 license that can be found in the LICENSE.txt file. + */ + +package org.jetbrains.compose.test.tests.unit + +import org.jetbrains.compose.desktop.application.internal.NativeImageMainThread +import org.junit.jupiter.api.Assertions.assertNotNull +import org.junit.jupiter.api.Assertions.assertNull +import org.junit.jupiter.api.Assertions.assertTrue +import org.junit.jupiter.api.Test + +class NativeImageMainThreadTest { + @Test + fun libericaNikIsRefusedWithTheFix() { + val release = """ + IMPLEMENTOR="BellSoft" + JAVA_RUNTIME_VERSION="25.0.4.1+2-LTS" + JAVA_VERSION="25.0.4.1" + """.trimIndent() + val problem = NativeImageMainThread.problem(release) + assertNotNull(problem) + assertTrue(problem!!.contains("graalvm-community"), problem) + } + + @Test + fun upstreamGraalVmIsAccepted() { + val release = """ + IMPLEMENTOR="GraalVM Community" + JAVA_VERSION="25.0.1" + """.trimIndent() + assertNull(NativeImageMainThread.problem(release)) + } + + @Test + fun theOptionKeepsMainOnTheFirstThread() { + assertTrue("-H:-RunMainInNewThread" in NativeImageMainThread.options) + } +} From 4f707e250b984bfdd7025643d3513d069e4cd1cd Mon Sep 17 00:00:00 2001 From: BREW <81044362+b-re-w@users.noreply.github.com> Date: Sun, 18 Oct 2026 11:50:12 +0900 Subject: [PATCH 2/2] Test: The macOS gate checks that an AWT-free build refuses Liberica NIK, with the reason --- .github/workflows/extended-native-image.yml | 23 +++++++++++++++++++++ 1 file changed, 23 insertions(+) diff --git a/.github/workflows/extended-native-image.yml b/.github/workflows/extended-native-image.yml index 443fdc3076..9b04b327f3 100644 --- a/.github/workflows/extended-native-image.yml +++ b/.github/workflows/extended-native-image.yml @@ -353,6 +353,29 @@ jobs: shell: bash run: ./gradlew --no-daemon --info packageNativeImage + # Liberica NIK runs main on a new thread on macOS whatever -H:-RunMainInNewThread says, and + # an AWT-free image built with it hangs at its first window call. packageNativeImage has to + # refuse it before building, with the reason. + - name: An AWT-free macOS image refuses Liberica NIK + if: runner.os == 'macOS' + working-directory: extended/native-image/hello-awtfree-link + env: + SKIKO_STATIC: ${{ runner.temp }}/skiko-static-nojawt/out/${{ matrix.name }} + CORE_EXTENDED: ${{ github.workspace }}/core-extended + shell: bash + run: | + set -euo pipefail + curl -fsSL -o "$RUNNER_TEMP/liberica.tar.gz" "$NIK_BASE/bellsoft-liberica-vm-full-openjdk25.0.4.1+2-25.0.4.1+1-macos-aarch64.tar.gz" + mkdir -p "$RUNNER_TEMP/liberica" && tar xzf "$RUNNER_TEMP/liberica.tar.gz" -C "$RUNNER_TEMP/liberica" + rel="$(find "$RUNNER_TEMP/liberica" -name release -type f | awk '{print length, $0}' | sort -n | head -1 | cut -d' ' -f2-)" + status=0 + GRAALVM_HOME="$(dirname "$rel")" ./gradlew --no-daemon packageNativeImage > "$RUNNER_TEMP/liberica.log" 2>&1 || status=$? + tail -20 "$RUNNER_TEMP/liberica.log" + [ "$status" -ne 0 ] || { echo "packageNativeImage built an AWT-free macOS image with Liberica NIK" >&2; exit 1; } + grep -q "This GraalVM is Liberica NIK" "$RUNNER_TEMP/liberica.log" || + { echo "it failed, but not with the reason" >&2; exit 1; } + echo "refused, with the reason" + - name: Start the AwtFree link sample working-directory: extended/native-image/hello-awtfree-link shell: bash