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 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) + } +}