Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
23 changes: 23 additions & 0 deletions .github/workflows/extended-native-image.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
11 changes: 8 additions & 3 deletions extended/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
11 changes: 8 additions & 3 deletions extended/README_ko.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 를
파일에서 불러올 수 없습니다.
Expand Down
4 changes: 3 additions & 1 deletion extended/native-image/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
3 changes: 0 additions & 3 deletions extended/native-image/hello-awtfree-link/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -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)) }
}
}
Expand Down
5 changes: 0 additions & 5 deletions extended/native-image/hello-awtfree/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
@@ -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<String> = 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."
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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(
Expand Down Expand Up @@ -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"
}

Expand Down
Original file line number Diff line number Diff line change
@@ -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)
}
}
Loading