From 2fe5ec8ac07b6414c15cf823391bcc443c39607f Mon Sep 17 00:00:00 2001 From: Quinn Klassen Date: Mon, 5 Oct 2026 09:11:40 -0700 Subject: [PATCH 1/2] Update API protos for Graal AOT and Kotlin runtimes Pin the API submodule to 1dd85fd479 from temporalio/api#880. Keep the API dependency update separate from the SDK runtime reporting changes. --- temporal-serviceclient/src/main/proto | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/temporal-serviceclient/src/main/proto b/temporal-serviceclient/src/main/proto index 3ebdff42a9..1dd85fd479 160000 --- a/temporal-serviceclient/src/main/proto +++ b/temporal-serviceclient/src/main/proto @@ -1 +1 @@ -Subproject commit 3ebdff42a9f07ac484b415fe8ff0b483b4ce3340 +Subproject commit 1dd85fd4797147a54461fbf24ca35b9a16a7e77a From b4ca68fe5f1060d1dbc739dcac8007112c3791cc Mon Sep 17 00:00:00 2001 From: Quinn Klassen Date: Mon, 5 Oct 2026 09:11:40 -0700 Subject: [PATCH 2/2] Report Kotlin and Graal runtimes in worker heartbeats Detect directly registered Kotlin workflow and activity implementations per worker and include the Kotlin version alongside the JVM runtime. Report Graal native-image execution when detected. Keep detection best effort and preserve telemetry opt-out and heartbeat acceptance behavior. --- CHANGELOG.md | 2 + .../worker/JavaHeartbeatImplementations.java | 31 +++ .../worker/KotlinWorkerHeartbeatTest.kt | 161 ++++++++++++++ .../client/WorkflowClientOptions.java | 7 +- .../worker/WorkerEnvironmentInfo.java | 102 ++++++++- .../main/java/io/temporal/worker/Worker.java | 22 +- .../worker/WorkerEnvironmentInfoTest.java | 200 +++++++++++++++++- 7 files changed, 511 insertions(+), 14 deletions(-) create mode 100644 temporal-kotlin/src/test/java/io/temporal/worker/JavaHeartbeatImplementations.java create mode 100644 temporal-kotlin/src/test/kotlin/io/temporal/worker/KotlinWorkerHeartbeatTest.kt diff --git a/CHANGELOG.md b/CHANGELOG.md index b27b44024d..67f5849a93 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -20,6 +20,8 @@ to docs, or any other relevant information. ## [Unreleased] ### Added +- Worker heartbeats now report detected Graal native-image and Kotlin runtimes alongside the JVM + version. Detection is best effort and respects the worker environment information opt-out. - Added experimental `ChildWorkflowOptions.Builder.setVersioningOverride` and `VersioningOverride.OneTimeVersioningOverride` for explicit pinned, auto-upgrade, and one-time child workflow routing. Invalid child overrides are reported as `InvalidVersioningOverrideFailure` diff --git a/temporal-kotlin/src/test/java/io/temporal/worker/JavaHeartbeatImplementations.java b/temporal-kotlin/src/test/java/io/temporal/worker/JavaHeartbeatImplementations.java new file mode 100644 index 0000000000..e0363c676e --- /dev/null +++ b/temporal-kotlin/src/test/java/io/temporal/worker/JavaHeartbeatImplementations.java @@ -0,0 +1,31 @@ +package io.temporal.worker; + +import io.temporal.activity.ActivityInterface; +import io.temporal.workflow.WorkflowInterface; +import io.temporal.workflow.WorkflowMethod; + +/** Java implementations without Kotlin metadata, even when Kotlin is on the classpath. */ +public final class JavaHeartbeatImplementations { + private JavaHeartbeatImplementations() {} + + @WorkflowInterface + public interface JavaWorkflow { + @WorkflowMethod + void execute(); + } + + public static class JavaWorkflowImpl implements JavaWorkflow { + @Override + public void execute() {} + } + + @ActivityInterface + public interface JavaActivity { + void executeActivity(); + } + + public static class JavaActivityImpl implements JavaActivity { + @Override + public void executeActivity() {} + } +} diff --git a/temporal-kotlin/src/test/kotlin/io/temporal/worker/KotlinWorkerHeartbeatTest.kt b/temporal-kotlin/src/test/kotlin/io/temporal/worker/KotlinWorkerHeartbeatTest.kt new file mode 100644 index 0000000000..7783656294 --- /dev/null +++ b/temporal-kotlin/src/test/kotlin/io/temporal/worker/KotlinWorkerHeartbeatTest.kt @@ -0,0 +1,161 @@ +package io.temporal.worker + +import io.temporal.activity.ActivityInterface +import io.temporal.api.worker.v1.EnvironmentInfo +import io.temporal.api.worker.v1.EnvironmentInfo.Runtime.RuntimeType +import io.temporal.client.WorkflowClientOptions +import io.temporal.testing.TestEnvironmentOptions +import io.temporal.testing.TestWorkflowEnvironment +import io.temporal.workflow.WorkflowInterface +import io.temporal.workflow.WorkflowMethod +import org.junit.After +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Before +import org.junit.Test +import java.time.Duration + +class KotlinWorkerHeartbeatTest { + private lateinit var testEnvironment: TestWorkflowEnvironment + + private val jvmRuntime = EnvironmentInfo.Runtime.newBuilder() + .setType(RuntimeType.RUNTIME_TYPE_JVM) + .setVersion("test-jvm-version") + .build() + private val environment = EnvironmentInfo.newBuilder() + .addRuntimes(jvmRuntime) + .build() + + @Before + fun setUp() { + // Exercise callbacks directly without a background heartbeat accepting the environment. + testEnvironment = TestWorkflowEnvironment.newInstance( + TestEnvironmentOptions.newBuilder() + .setWorkflowClientOptions( + WorkflowClientOptions.newBuilder() + .setWorkerHeartbeatInterval(Duration.ofSeconds(-1)) + .build() + ) + .build() + ) + } + + @After + fun tearDown() { + testEnvironment.close() + } + + @Test + fun kotlinWorkflowRegistrationReportsKotlin() { + val worker = testEnvironment.newWorker("kotlin-workflow") + worker.registerWorkflowImplementationTypes(KotlinWorkflowImpl::class.java) + testEnvironment.start() + + assertKotlinEnvironment(worker.buildHeartbeatCallback("group", environment).get().environment) + } + + @Test + fun kotlinWorkflowRegistrationWithOptionsReportsKotlin() { + val worker = testEnvironment.newWorker("kotlin-workflow-options") + worker.registerWorkflowImplementationTypes( + WorkflowImplementationOptions.newBuilder().build(), + KotlinWorkflowImpl::class.java + ) + testEnvironment.start() + + assertKotlinEnvironment(worker.buildHeartbeatCallback("group", environment).get().environment) + } + + @Test + fun kotlinActivityRegistrationReportsKotlinForLocalOnlyWorker() { + val worker = testEnvironment.newWorker( + "kotlin-local-activity", + WorkerOptions.newBuilder().setLocalActivityWorkerOnly(true).build() + ) + worker.registerActivitiesImplementations(KotlinActivityImpl()) + testEnvironment.start() + + assertKotlinEnvironment(worker.buildHeartbeatCallback("group", environment).get().environment) + } + + @Test + fun kotlinDetectionIsPerWorkerNotPerClasspathOrFactory() { + val kotlinWorker = testEnvironment.newWorker("kotlin") + kotlinWorker.registerWorkflowImplementationTypes(KotlinWorkflowImpl::class.java) + val javaWorker = testEnvironment.newWorker("java") + javaWorker.registerWorkflowImplementationTypes(JavaHeartbeatImplementations.JavaWorkflowImpl::class.java) + javaWorker.registerActivitiesImplementations(JavaHeartbeatImplementations.JavaActivityImpl()) + val unregisteredWorker = testEnvironment.newWorker("unregistered") + testEnvironment.start() + + assertKotlinEnvironment(kotlinWorker.buildHeartbeatCallback("group", environment).get().environment) + assertEquals(environment, javaWorker.buildHeartbeatCallback("group", environment).get().environment) + assertEquals(environment, unregisteredWorker.buildHeartbeatCallback("group", environment).get().environment) + } + + @Test + fun environmentIsRetriedUntilHeartbeatAccepted() { + val worker = testEnvironment.newWorker("retry") + worker.registerWorkflowImplementationTypes(KotlinWorkflowImpl::class.java) + testEnvironment.start() + val heartbeat = worker.buildHeartbeatCallback("group", environment) + + val first = heartbeat.get() + assertTrue(first.hasEnvironment()) + assertKotlinEnvironment(first.environment) + repeat(3) { + val retry = heartbeat.get() + assertTrue(retry.hasEnvironment()) + assertEquals(first.environment, retry.environment) + } + + worker.onHeartbeatAccepted() + repeat(3) { + assertFalse(heartbeat.get().hasEnvironment()) + } + } + + @Test + fun nullEnvironmentOptsOutEvenForKotlinImplementations() { + val worker = testEnvironment.newWorker("optout") + worker.registerWorkflowImplementationTypes(KotlinWorkflowImpl::class.java) + worker.registerActivitiesImplementations(KotlinActivityImpl()) + testEnvironment.start() + val heartbeat = worker.buildHeartbeatCallback("group", null) + + repeat(3) { + assertFalse(heartbeat.get().hasEnvironment()) + } + worker.onHeartbeatAccepted() + assertFalse(heartbeat.get().hasEnvironment()) + } + + private fun assertKotlinEnvironment(actual: EnvironmentInfo) { + assertEquals(2, actual.runtimesCount) + assertEquals(jvmRuntime, actual.runtimesList.single { it.type == RuntimeType.RUNTIME_TYPE_JVM }) + val kotlinRuntime = actual.runtimesList.single { it.type == RuntimeType.RUNTIME_TYPE_KOTLIN } + assertEquals(KotlinVersion.CURRENT.toString(), kotlinRuntime.version) + // Augmenting the heartbeat must not mutate the caller's environment. + assertEquals(listOf(jvmRuntime), environment.runtimesList) + } + + @WorkflowInterface + interface KotlinWorkflow { + @WorkflowMethod + fun execute() + } + + class KotlinWorkflowImpl : KotlinWorkflow { + override fun execute() {} + } + + @ActivityInterface + interface KotlinActivity { + fun executeActivity() + } + + class KotlinActivityImpl : KotlinActivity { + override fun executeActivity() {} + } +} diff --git a/temporal-sdk/src/main/java/io/temporal/client/WorkflowClientOptions.java b/temporal-sdk/src/main/java/io/temporal/client/WorkflowClientOptions.java index 3015d20233..0450786b3d 100644 --- a/temporal-sdk/src/main/java/io/temporal/client/WorkflowClientOptions.java +++ b/temporal-sdk/src/main/java/io/temporal/client/WorkflowClientOptions.java @@ -190,9 +190,10 @@ public Builder setWorkerHeartbeatInterval(Duration workerHeartbeatInterval) { } /** - * Disables reporting the JVM version, detected hosting environments (Docker, Kubernetes, cloud - * platforms), and OS platform in worker heartbeats. This information is sent once per worker, - * with the first heartbeat accepted by the server. + * Disables reporting runtime versions (JVM, detected Graal native images, and Kotlin), detected + * hosting environments (Docker, Kubernetes, cloud platforms), and OS platform in worker + * heartbeats. This information is sent once per worker, with the first heartbeat accepted by + * the server. * * @param disableWorkerEnvironmentInfo true to omit environment information from heartbeats */ diff --git a/temporal-sdk/src/main/java/io/temporal/internal/worker/WorkerEnvironmentInfo.java b/temporal-sdk/src/main/java/io/temporal/internal/worker/WorkerEnvironmentInfo.java index cd33aad2fa..5e0277a1af 100644 --- a/temporal-sdk/src/main/java/io/temporal/internal/worker/WorkerEnvironmentInfo.java +++ b/temporal-sdk/src/main/java/io/temporal/internal/worker/WorkerEnvironmentInfo.java @@ -11,6 +11,8 @@ import io.temporal.api.worker.v1.EnvironmentInfo.Runtime.RuntimeType; import io.temporal.api.worker.v1.EnvironmentInfo.WindowsPlatform; import java.io.IOException; +import java.lang.annotation.Annotation; +import java.lang.annotation.AnnotationFormatError; import java.nio.charset.StandardCharsets; import java.nio.file.Files; import java.nio.file.Path; @@ -33,28 +35,110 @@ public final class WorkerEnvironmentInfo { private WorkerEnvironmentInfo() {} /** - * Never throws: this runs during client creation, and telemetry must not break it. System - * property, environment, and filesystem access can all fail under a security manager, in which - * case whatever was collected before the failure is returned. + * Best effort: this runs during client creation, and telemetry must not break it. Each probe is + * isolated so unavailable runtime information does not prevent platform or hosting detection. */ public static EnvironmentInfo detect() { + return detect(System::getProperty); + } + + static EnvironmentInfo detect(Function properties) { EnvironmentInfo.Builder builder = EnvironmentInfo.newBuilder(); + builder.addRuntimes( + Runtime.newBuilder() + .setType(RuntimeType.RUNTIME_TYPE_JVM) + .setVersion(propertyValue(properties, "java.version"))); + try { + if ("runtime".equals(properties.apply("org.graalvm.nativeimage.imagecode"))) { + builder.addRuntimes( + Runtime.newBuilder() + .setType(RuntimeType.RUNTIME_TYPE_GRAAL_AOT) + .setVersion(propertyValue(properties, "org.graalvm.version"))); + } + } catch (RuntimeException | LinkageError e) { + log.info("Failed to detect Graal native-image runtime", e); + } try { - builder.addRuntimes( - Runtime.newBuilder() - .setType(RuntimeType.RUNTIME_TYPE_JVM) - .setVersion(nullToEmpty(System.getProperty("java.version")))); builder.addAllHostingEnvironments(detectHostingEnvironments(System::getenv)); + } catch (RuntimeException | LinkageError e) { + log.info("Failed to detect worker hosting environments", e); + } + try { Platform platform = detectPlatform(); if (platform != null) { builder.setPlatform(platform); } - } catch (RuntimeException e) { - log.info("Failed to detect worker environment information, reporting partial results", e); + } catch (RuntimeException | LinkageError e) { + log.info("Failed to detect worker platform", e); } return builder.build(); } + private static String propertyValue(Function properties, String name) { + try { + return nullToEmpty(properties.apply(name)); + } catch (RuntimeException | LinkageError e) { + return ""; + } + } + + /** + * Detects Kotlin implementation classes without a Kotlin dependency or assuming a shared + * annotation class loader. + */ + public static boolean isKotlinImplementation(@Nullable Class implementationClass) { + if (implementationClass == null) { + return false; + } + try { + for (Annotation annotation : implementationClass.getDeclaredAnnotations()) { + if ("kotlin.Metadata".equals(annotation.annotationType().getName())) { + return true; + } + } + } catch (RuntimeException | LinkageError | AnnotationFormatError e) { + log.info("Failed to detect Kotlin implementation metadata", e); + } + return false; + } + + /** + * Returns immutable environment information augmented with Kotlin when the implementation has + * Kotlin metadata. A null environment (telemetry opt-out) is preserved, as are existing runtimes. + */ + @Nullable + public static EnvironmentInfo withKotlinRuntime( + @Nullable EnvironmentInfo info, @Nullable Class implementationClass) { + if (info == null) { + return null; + } + for (Runtime runtime : info.getRuntimesList()) { + if (runtime.getType() == RuntimeType.RUNTIME_TYPE_KOTLIN) { + return info; + } + } + if (!isKotlinImplementation(implementationClass)) { + return info; + } + return info.toBuilder() + .addRuntimes( + Runtime.newBuilder() + .setType(RuntimeType.RUNTIME_TYPE_KOTLIN) + .setVersion(kotlinVersion(implementationClass))) + .build(); + } + + private static String kotlinVersion(Class implementationClass) { + try { + Class version = + Class.forName("kotlin.KotlinVersion", true, implementationClass.getClassLoader()); + Object current = version.getField("CURRENT").get(null); + return current == null ? "" : current.toString(); + } catch (ReflectiveOperationException | RuntimeException | LinkageError e) { + return ""; + } + } + /** * Several environments may be detected at once, e.g. Docker inside Kubernetes or Azure Functions * inside Azure App Service. diff --git a/temporal-sdk/src/main/java/io/temporal/worker/Worker.java b/temporal-sdk/src/main/java/io/temporal/worker/Worker.java index 91f5515ea8..50c6590cd2 100644 --- a/temporal-sdk/src/main/java/io/temporal/worker/Worker.java +++ b/temporal-sdk/src/main/java/io/temporal/worker/Worker.java @@ -84,6 +84,7 @@ public final class Worker { // Reported in every heartbeat (including the one embedded in ShutdownWorkerRequest) until the // server accepts one, then cleared so it is sent only once per worker. private final AtomicReference pendingEnvironmentInfo = new AtomicReference<>(); + private final AtomicReference> kotlinImplementationClass = new AtomicReference<>(); private static final class TaskSnapshot { final int processed; @@ -285,6 +286,7 @@ public void registerWorkflowImplementationTypes(Class... workflowImplementati workflowWorker.registerWorkflowImplementationTypes( WorkflowImplementationOptions.newBuilder().build(), workflowImplementationClasses); + recordKotlinImplementations(workflowImplementationClasses); } /** @@ -309,6 +311,7 @@ public void registerWorkflowImplementationTypes( "registerWorkflowImplementationTypes is not allowed after worker has started"); workflowWorker.registerWorkflowImplementationTypes(options, workflowImplementationClasses); + recordKotlinImplementations(workflowImplementationClasses); } /** @@ -485,6 +488,22 @@ public void registerActivitiesImplementations(Object... activityImplementations) activityWorker.registerActivityImplementations(activityImplementations); } workflowWorker.registerLocalActivityImplementations(activityImplementations); + for (Object implementation : activityImplementations) { + recordKotlinImplementations(implementation.getClass()); + } + } + + private void recordKotlinImplementations(Class... implementationClasses) { + if (clientOptions.isWorkerEnvironmentInfoDisabled() + || kotlinImplementationClass.get() != null) { + return; + } + for (Class implementationClass : implementationClasses) { + if (WorkerEnvironmentInfo.isKotlinImplementation(implementationClass)) { + kotlinImplementationClass.compareAndSet(null, implementationClass); + return; + } + } } /** @@ -607,7 +626,8 @@ void onHeartbeatAccepted() { Supplier buildHeartbeatCallback( String workerGroupingKey, @Nullable EnvironmentInfo environmentInfo) { - pendingEnvironmentInfo.set(environmentInfo); + pendingEnvironmentInfo.set( + WorkerEnvironmentInfo.withKotlinRuntime(environmentInfo, kotlinImplementationClass.get())); // The callback can be invoked concurrently from the heartbeat scheduler and the shutdown path final Object callbackLock = new Object(); final AtomicReference lastHeartbeatTime = new AtomicReference<>(null); diff --git a/temporal-sdk/src/test/java/io/temporal/internal/worker/WorkerEnvironmentInfoTest.java b/temporal-sdk/src/test/java/io/temporal/internal/worker/WorkerEnvironmentInfoTest.java index 1c84aa37b0..2e1f4812ea 100644 --- a/temporal-sdk/src/test/java/io/temporal/internal/worker/WorkerEnvironmentInfoTest.java +++ b/temporal-sdk/src/test/java/io/temporal/internal/worker/WorkerEnvironmentInfoTest.java @@ -7,21 +7,33 @@ import io.temporal.api.worker.v1.EnvironmentInfo.HostingEnvironment; import io.temporal.api.worker.v1.EnvironmentInfo.HostingEnvironment.HostingEnvironmentType; import io.temporal.api.worker.v1.EnvironmentInfo.Runtime.RuntimeType; +import java.io.File; +import java.net.URI; +import java.net.URL; +import java.net.URLClassLoader; import java.util.Arrays; import java.util.Collections; import java.util.HashMap; import java.util.List; import java.util.Map; import java.util.stream.Collectors; +import javax.tools.JavaCompiler; +import javax.tools.JavaFileObject; +import javax.tools.SimpleJavaFileObject; +import javax.tools.ToolProvider; +import org.junit.Rule; import org.junit.Test; +import org.junit.rules.TemporaryFolder; public class WorkerEnvironmentInfoTest { + @Rule public TemporaryFolder temporaryFolder = new TemporaryFolder(); + @Test public void detectReportsJvmRuntimeAndPlatform() { EnvironmentInfo info = WorkerEnvironmentInfo.detect(); - assertEquals(1, info.getRuntimesCount()); + assertTrue(info.getRuntimesCount() >= 1); assertEquals(RuntimeType.RUNTIME_TYPE_JVM, info.getRuntimes(0).getType()); assertEquals(System.getProperty("java.version"), info.getRuntimes(0).getVersion()); @@ -43,6 +55,192 @@ public void detectReportsJvmRuntimeAndPlatform() { } } + @Test + public void detectGraalNativeImageRuntime() { + Map properties = new HashMap<>(); + properties.put("java.version", "21.0.2"); + properties.put("org.graalvm.nativeimage.imagecode", "runtime"); + properties.put("org.graalvm.version", "24.0.1"); + EnvironmentInfo info = WorkerEnvironmentInfo.detect(properties::get); + assertEquals(2, info.getRuntimesCount()); + assertEquals(RuntimeType.RUNTIME_TYPE_JVM, info.getRuntimes(0).getType()); + assertEquals("21.0.2", info.getRuntimes(0).getVersion()); + assertEquals(RuntimeType.RUNTIME_TYPE_GRAAL_AOT, info.getRuntimes(1).getType()); + assertEquals("24.0.1", info.getRuntimes(1).getVersion()); + + properties.remove("org.graalvm.version"); + assertEquals("", WorkerEnvironmentInfo.detect(properties::get).getRuntimes(1).getVersion()); + properties.put("org.graalvm.nativeimage.imagecode", "buildtime"); + assertEquals(1, WorkerEnvironmentInfo.detect(properties::get).getRuntimesCount()); + properties.remove("org.graalvm.nativeimage.imagecode"); + assertEquals(1, WorkerEnvironmentInfo.detect(properties::get).getRuntimesCount()); + } + + @Test + public void runtimeProbeFailuresAreIsolated() { + for (boolean linkageError : new boolean[] {false, true}) { + EnvironmentInfo baseline = WorkerEnvironmentInfo.detect(); + EnvironmentInfo info = + WorkerEnvironmentInfo.detect( + name -> { + if ("org.graalvm.nativeimage.imagecode".equals(name)) { + throwProbeFailure(linkageError); + } + return System.getProperty(name); + }); + assertEquals(RuntimeType.RUNTIME_TYPE_JVM, info.getRuntimes(0).getType()); + assertEquals(System.getProperty("java.version"), info.getRuntimes(0).getVersion()); + assertEquals(baseline.getPlatform(), info.getPlatform()); + assertEquals(baseline.getHostingEnvironmentsList(), info.getHostingEnvironmentsList()); + + info = + WorkerEnvironmentInfo.detect( + name -> { + if ("org.graalvm.nativeimage.imagecode".equals(name)) { + return "runtime"; + } + throwProbeFailure(linkageError); + return null; + }); + assertEquals(2, info.getRuntimesCount()); + assertEquals("", info.getRuntimes(0).getVersion()); + assertEquals(RuntimeType.RUNTIME_TYPE_GRAAL_AOT, info.getRuntimes(1).getType()); + assertEquals("", info.getRuntimes(1).getVersion()); + assertEquals(baseline.getPlatform(), info.getPlatform()); + assertEquals(baseline.getHostingEnvironmentsList(), info.getHostingEnvironmentsList()); + } + } + + @Test + public void kotlinAugmentationPreservesOptOutAndJavaImplementations() { + EnvironmentInfo info = WorkerEnvironmentInfo.detect(); + assertFalse(WorkerEnvironmentInfo.isKotlinImplementation(null)); + assertFalse(WorkerEnvironmentInfo.isKotlinImplementation(getClass())); + assertNull(WorkerEnvironmentInfo.withKotlinRuntime(null, getClass())); + assertSame(info, WorkerEnvironmentInfo.withKotlinRuntime(info, null)); + assertSame(info, WorkerEnvironmentInfo.withKotlinRuntime(info, getClass())); + } + + @Test + public void kotlinMetadataDetectedAcrossClassLoaderBoundary() throws Exception { + File classes = compileKotlinFixtures(true); + try (URLClassLoader loader = fixtureLoader(classes, null, false)) { + Class implementation = loader.loadClass("fixtures.Implementation"); + assertTrue(WorkerEnvironmentInfo.isKotlinImplementation(implementation)); + EnvironmentInfo info = WorkerEnvironmentInfo.detect(); + EnvironmentInfo augmented = WorkerEnvironmentInfo.withKotlinRuntime(info, implementation); + assertEquals(info.getRuntimesCount() + 1, augmented.getRuntimesCount()); + assertEquals(info.getPlatform(), augmented.getPlatform()); + assertEquals(info.getHostingEnvironmentsList(), augmented.getHostingEnvironmentsList()); + assertEquals( + RuntimeType.RUNTIME_TYPE_KOTLIN, + augmented.getRuntimes(info.getRuntimesCount()).getType()); + assertEquals("2.1.0", augmented.getRuntimes(info.getRuntimesCount()).getVersion()); + assertSame(augmented, WorkerEnvironmentInfo.withKotlinRuntime(augmented, implementation)); + assertEquals( + info.getRuntimesList(), augmented.getRuntimesList().subList(0, info.getRuntimesCount())); + assertNull(WorkerEnvironmentInfo.withKotlinRuntime(null, implementation)); + } + } + + @Test + public void kotlinVersionIsOptional() throws Exception { + File classes = compileKotlinFixtures(false); + try (URLClassLoader loader = fixtureLoader(classes, null, false)) { + EnvironmentInfo info = + WorkerEnvironmentInfo.withKotlinRuntime( + EnvironmentInfo.getDefaultInstance(), loader.loadClass("fixtures.Implementation")); + assertEquals(RuntimeType.RUNTIME_TYPE_KOTLIN, info.getRuntimes(0).getType()); + assertEquals("", info.getRuntimes(0).getVersion()); + } + } + + @Test + public void kotlinProbeFailuresAreBestEffort() throws Exception { + File classes = compileKotlinFixtures(true); + for (boolean linkageError : new boolean[] {false, true}) { + try (URLClassLoader loader = fixtureLoader(classes, "kotlin.Metadata", linkageError)) { + Class implementation = loader.loadClass("fixtures.Implementation"); + EnvironmentInfo info = EnvironmentInfo.getDefaultInstance(); + assertFalse(WorkerEnvironmentInfo.isKotlinImplementation(implementation)); + assertSame(info, WorkerEnvironmentInfo.withKotlinRuntime(info, implementation)); + } + try (URLClassLoader loader = fixtureLoader(classes, "kotlin.KotlinVersion", linkageError)) { + EnvironmentInfo info = + WorkerEnvironmentInfo.withKotlinRuntime( + EnvironmentInfo.getDefaultInstance(), loader.loadClass("fixtures.Implementation")); + assertEquals(RuntimeType.RUNTIME_TYPE_KOTLIN, info.getRuntimes(0).getType()); + assertEquals("", info.getRuntimes(0).getVersion()); + } + } + } + + private static void throwProbeFailure(boolean linkageError) { + if (linkageError) { + throw new LinkageError("Unavailable telemetry probe."); + } + throw new SecurityException("Unavailable telemetry probe."); + } + + private URLClassLoader fixtureLoader(File classes, String failingClass, boolean linkageError) + throws Exception { + // No parent: the fixtures neither need nor accidentally use a real Kotlin dependency. + return new URLClassLoader(new URL[] {classes.toURI().toURL()}, null) { + @Override + protected Class loadClass(String name, boolean resolve) throws ClassNotFoundException { + if (name.equals(failingClass)) { + throwProbeFailure(linkageError); + } + return super.loadClass(name, resolve); + } + }; + } + + private File compileKotlinFixtures(boolean includeVersion) throws Exception { + File classes = temporaryFolder.newFolder(); + List sources = + new java.util.ArrayList<>( + Arrays.asList( + source( + "kotlin.Metadata", + "package kotlin; @java.lang.annotation.Retention(" + + "java.lang.annotation.RetentionPolicy.RUNTIME) public @interface Metadata {}"), + source( + "fixtures.Implementation", + "package fixtures; @kotlin.Metadata public class Implementation {}"))); + if (includeVersion) { + sources.add( + source( + "kotlin.KotlinVersion", + "package kotlin; public class KotlinVersion {" + + " public static final KotlinVersion CURRENT = new KotlinVersion();" + + " public String toString() { return \"2.1.0\"; } }")); + } + JavaCompiler compiler = ToolProvider.getSystemJavaCompiler(); + assertNotNull("The fixture compiler requires a JDK.", compiler); + assertTrue( + compiler + .getTask( + null, + null, + null, + Arrays.asList("-source", "8", "-target", "8", "-d", classes.getAbsolutePath()), + null, + sources) + .call()); + return classes; + } + + private static JavaFileObject source(String name, String contents) { + return new SimpleJavaFileObject( + URI.create("string:///" + name.replace('.', '/') + ".java"), JavaFileObject.Kind.SOURCE) { + @Override + public CharSequence getCharContent(boolean ignoreEncodingErrors) { + return contents; + } + }; + } + @Test public void detectHostingEnvironments() { Map env = new HashMap<>();