From c868d2a5b08f2a60caf0e9c4c49a31dab7d69488 Mon Sep 17 00:00:00 2001 From: twisti <76837088+twisti-dev@users.noreply.github.com> Date: Fri, 27 Mar 2026 11:55:16 +0100 Subject: [PATCH 1/4] feat: implement hidden-class invoker infrastructure with utility and factory classes --- gradle.properties | 2 +- .../api/surf-api-core-api.api | 30 +++ .../core/api/invoker/HiddenInvokerUtil.java | 190 ++++++++++++++++++ .../core/api/invoker/InvokerClassData.java | 30 +++ .../core/api/invoker/InvokerFactory.java | 133 ++++++++++++ .../core/api/invoker/SuspendInvokerSupport.kt | 81 ++++++++ .../api/surf-api-shared-public.api | 3 + .../surfapi/shared/api/util/internal-api.kt | 26 ++- 8 files changed, 493 insertions(+), 2 deletions(-) create mode 100644 surf-api-core/surf-api-core-api/src/main/java/dev/slne/surf/surfapi/core/api/invoker/HiddenInvokerUtil.java create mode 100644 surf-api-core/surf-api-core-api/src/main/java/dev/slne/surf/surfapi/core/api/invoker/InvokerClassData.java create mode 100644 surf-api-core/surf-api-core-api/src/main/java/dev/slne/surf/surfapi/core/api/invoker/InvokerFactory.java create mode 100644 surf-api-core/surf-api-core-api/src/main/kotlin/dev/slne/surf/surfapi/core/api/invoker/SuspendInvokerSupport.kt diff --git a/gradle.properties b/gradle.properties index b02770160..6d89919f0 100644 --- a/gradle.properties +++ b/gradle.properties @@ -7,6 +7,6 @@ org.jetbrains.dokka.experimental.gradle.pluginMode=V2Enabled javaVersion=25 mcVersion=1.21.11 group=dev.slne.surf -version=1.21.11-2.71.0 +version=1.21.11-2.72.0 relocationPrefix=dev.slne.surf.surfapi.libs snapshot=false diff --git a/surf-api-core/surf-api-core-api/api/surf-api-core-api.api b/surf-api-core/surf-api-core-api/api/surf-api-core-api.api index 23feb1982..f0652aab6 100644 --- a/surf-api-core/surf-api-core-api/api/surf-api-core-api.api +++ b/surf-api-core/surf-api-core-api/api/surf-api-core-api.api @@ -6473,6 +6473,36 @@ public final class dev/slne/surf/surfapi/core/api/generated/VanillaAdvancementKe public static final field UPGRADE_TOOLS Lnet/kyori/adventure/key/Key; } +public final class dev/slne/surf/surfapi/core/api/invoker/InvokerClassData : java/lang/Record { + public fun (Ljava/lang/reflect/Method;Ljava/lang/invoke/MethodHandle;Ljava/lang/Class;Z)V + public final fun equals (Ljava/lang/Object;)Z + public final fun hashCode ()I + public fun isSuspend ()Z + public fun method ()Ljava/lang/reflect/Method; + public fun methodHandle ()Ljava/lang/invoke/MethodHandle; + public fun payloadClass ()Ljava/lang/Class; + public final fun toString ()Ljava/lang/String; +} + +public class dev/slne/surf/surfapi/core/api/invoker/InvokerFactory { + public fun (Ljava/lang/Class;Ljava/lang/Class;)V + public fun (Ljava/lang/Class;Ljava/lang/Class;Ljava/lang/invoke/MethodHandles$Lookup;)V + public fun canAccess (Ljava/lang/Object;Ljava/lang/reflect/Method;)Z + public fun create (Ljava/lang/Object;Ljava/lang/reflect/Method;Ljava/lang/Class;)Ljava/lang/Object; + public fun create (Ljava/lang/Object;Ljava/lang/reflect/Method;Ljava/lang/Class;Z)Ljava/lang/Object; + public fun getLookup ()Ljava/lang/invoke/MethodHandles$Lookup; + public fun getTemplateClassBytes ()[B +} + +public final class dev/slne/surf/surfapi/core/api/invoker/SuspendInvokerSupport { + public static final field INSTANCE Ldev/slne/surf/surfapi/core/api/invoker/SuspendInvokerSupport; + public static final fun invokeSuspend (Lkotlinx/coroutines/CoroutineScope;Ljava/lang/invoke/MethodHandle;Ljava/lang/Object;)V + public static final fun invokeSuspend (Lkotlinx/coroutines/CoroutineScope;Ljava/lang/invoke/MethodHandle;Ljava/lang/Object;Lkotlin/jvm/functions/Function1;)V + public static synthetic fun invokeSuspend$default (Lkotlinx/coroutines/CoroutineScope;Ljava/lang/invoke/MethodHandle;Ljava/lang/Object;Lkotlin/jvm/functions/Function1;ILjava/lang/Object;)V + public static final fun invokeSuspendDirect (Ljava/lang/invoke/MethodHandle;Ljava/lang/Object;Lkotlin/coroutines/CoroutineContext;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; + public static synthetic fun invokeSuspendDirect$default (Ljava/lang/invoke/MethodHandle;Ljava/lang/Object;Lkotlin/coroutines/CoroutineContext;Lkotlin/coroutines/Continuation;ILjava/lang/Object;)Ljava/lang/Object; +} + public final class dev/slne/surf/surfapi/core/api/math/VoxelLineTracer { public static final field INSTANCE Ldev/slne/surf/surfapi/core/api/math/VoxelLineTracer; public final fun trace (Lorg/spongepowered/math/vector/Vector3d;Lorg/spongepowered/math/vector/Vector3d;)Lkotlin/sequences/Sequence; diff --git a/surf-api-core/surf-api-core-api/src/main/java/dev/slne/surf/surfapi/core/api/invoker/HiddenInvokerUtil.java b/surf-api-core/surf-api-core-api/src/main/java/dev/slne/surf/surfapi/core/api/invoker/HiddenInvokerUtil.java new file mode 100644 index 000000000..a49811d48 --- /dev/null +++ b/surf-api-core/surf-api-core-api/src/main/java/dev/slne/surf/surfapi/core/api/invoker/HiddenInvokerUtil.java @@ -0,0 +1,190 @@ +package dev.slne.surf.surfapi.core.api.invoker; + +import kotlin.coroutines.Continuation; +import org.jspecify.annotations.NullMarked; + +import java.lang.constant.ConstantDescs; +import java.lang.invoke.MethodHandle; +import java.lang.invoke.MethodHandles; +import java.lang.invoke.MethodHandles.Lookup; +import java.lang.invoke.MethodType; +import java.lang.reflect.Method; +import java.util.List; + +/** + * Shared utility for creating and initializing hidden class–based invokers. + * + *

This class encapsulates the low-level JVM hidden class machinery used by + * all surf-* invoker factories (redis events, redis requests, rabbitmq handlers, + * packet listeners, etc.). + * + *

Hidden class lifecycle

+ *
    + *
  1. {@link #createInvoker} packs the target instance, payload class, method, + * a private lookup, and a suspend flag into a {@link List} and passes it as + * class data to {@link Lookup#defineHiddenClassWithClassData}.
  2. + *
  3. The hidden class's static initializer calls back to the factory's + * {@code classData()} method, which delegates to {@link #loadClassData}.
  4. + *
  5. {@link #loadClassData} extracts the individual components via + * {@link MethodHandles#classDataAt}, resolves the method into a bound + * {@link MethodHandle}, and returns them as an {@link InvokerClassData} record.
  6. + *
+ * + *

Suspend support

+ *

When the target method is a Kotlin suspend function, the class data includes + * {@code isSuspend = true} and the MethodHandle is bound with the Continuation-accepting + * signature. The hidden class template is responsible for creating and managing the + * Continuation (see SuspendInvokerTemplate). + * + *

This class is package-private and not intended for external use. + */ +@NullMarked +final class HiddenInvokerUtil { + + private HiddenInvokerUtil() { + throw new UnsupportedOperationException(); + } + + /** + * Checks whether the given method is a Kotlin suspend function. + * + *

Suspend functions are compiled with an additional + * {@link Continuation} parameter as the last parameter, + * and return {@link Object}. + */ + static boolean isSuspendFunction(final Method method) { + final Class[] params = method.getParameterTypes(); + if (params.length == 0) return false; + + final Class lastParam = params[params.length - 1]; + return Continuation.class.isAssignableFrom(lastParam); + } + + /** + * Checks whether a hidden class invoker can be created for the given target and method. + * Validates that privateLookupIn succeeds and the method can be unreflected. + * + * @param target the listener/handler instance + * @param method the handler method + * @param lookup the lookup to use for access checks + * @return true if {@link #createInvoker} will succeed + */ + static boolean canAccess(final Object target, final Method method, final MethodHandles.Lookup lookup) { + try { + MethodHandles.privateLookupIn(target.getClass(), lookup).unreflect(method); + return true; + } catch (IllegalAccessException e) { + return false; + } + } + + /** + * Defines a new hidden class from the given template bytecode and returns a new instance + * of the specified invoker interface. + * + *

The class data passed to the hidden class consists of: + *

    + *
  1. The target handler/listener instance
  2. + *
  3. The payload class (event or request type)
  4. + *
  5. The handler {@link Method}
  6. + *
  7. A {@link MethodHandles.Lookup} with private access to the target's class
  8. + *
  9. A {@link Boolean} indicating whether the method is a suspend function
  10. + *
+ * + * @param the invoker interface type + * @param lookup the lookup used to define the hidden class + * @param templateBytes the bytecode of the template class + * @param invokerInterface the interface the hidden class implements + * @param target the handler/listener instance to bind the MethodHandle to + * @param method the handler method to invoke + * @param payloadClass the concrete event or request class the handler accepts + * @return a new instance of the hidden class, cast to {@code I} + * @throws ReflectiveOperationException if hidden class definition or instantiation fails + */ + static I createInvoker( + final MethodHandles.Lookup lookup, + final byte[] templateBytes, + final Class invokerInterface, + final Object target, + final Method method, + final Class payloadClass + ) throws ReflectiveOperationException { + final boolean isSuspend = isSuspendFunction(method); + return createInvoker(lookup, templateBytes, invokerInterface, target, method, payloadClass, isSuspend); + } + + /** + * Overload that allows explicitly specifying the suspend flag. + * Use this when you want to force suspend=false even if the method has a Continuation param. + */ + static I createInvoker( + final MethodHandles.Lookup lookup, + final byte[] templateBytes, + final Class invokerInterface, + final Object target, + final Method method, + final Class payloadClass, + final boolean isSuspend + ) throws ReflectiveOperationException { + final MethodHandles.Lookup privateLookupIn = MethodHandles.privateLookupIn(target.getClass(), lookup); + final List classData = List.of(target, payloadClass, method, privateLookupIn, isSuspend); + final MethodHandles.Lookup hiddenClassLookup = lookup.defineHiddenClassWithClassData(templateBytes, classData, true); + + return hiddenClassLookup.lookupClass() + .asSubclass(invokerInterface) + .getDeclaredConstructor() + .newInstance(); + } + + /** + * Extracts and resolves the class data that was passed to + * {@link MethodHandles.Lookup#defineHiddenClassWithClassData}. + * + *

For non-suspend methods, the MethodHandle is bound and type-erased to + * {@code methodType}. For suspend methods, the MethodHandle is bound and + * type-erased to {@code suspendMethodType} (which includes a trailing + * Continuation parameter and returns Object). + * + * @param lookup the hidden class's own lookup + * @param methodType the expected method type for non-suspend handlers + * @param suspendMethodType the expected method type for suspend handlers + * (must include Continuation as last param, return Object) + * @return an {@link InvokerClassData} record + * @throws ReflectiveOperationException if class data extraction or handle resolution fails + */ + static InvokerClassData loadClassData( + final MethodHandles.Lookup lookup, + final MethodType methodType, + final MethodType suspendMethodType + ) throws ReflectiveOperationException { + final Object target = MethodHandles.classDataAt(lookup, ConstantDescs.DEFAULT_NAME, Object.class, 0); + final Class payload = MethodHandles.classDataAt(lookup, ConstantDescs.DEFAULT_NAME, Class.class, 1); + final Method method = MethodHandles.classDataAt(lookup, ConstantDescs.DEFAULT_NAME, Method.class, 2); + final MethodHandles.Lookup privateLookupIn = MethodHandles.classDataAt(lookup, ConstantDescs.DEFAULT_NAME, MethodHandles.Lookup.class, 3); + final boolean isSuspend = MethodHandles.classDataAt(lookup, ConstantDescs.DEFAULT_NAME, Boolean.class, 4); + + final MethodType targetType = isSuspend ? suspendMethodType : methodType; + final MethodHandle handle = privateLookupIn.unreflect(method).bindTo(target).asType(targetType); + + return new InvokerClassData(method, handle, payload, isSuspend); + } + + /** + * Overload for factories that don't support suspend (backward compatible). + * Suspend methods will cause an error at template classData() time. + */ + static InvokerClassData loadClassData( + final MethodHandles.Lookup lookup, + final MethodType methodType + ) throws ReflectiveOperationException { + return loadClassData(lookup, methodType, methodType); + } + + /** + * Re-throws the given Throwable without requiring a checked exception declaration. + */ + @SuppressWarnings("unchecked") + public static void sneakyThrow(final Throwable t) throws T { + throw (T) t; + } +} \ No newline at end of file diff --git a/surf-api-core/surf-api-core-api/src/main/java/dev/slne/surf/surfapi/core/api/invoker/InvokerClassData.java b/surf-api-core/surf-api-core-api/src/main/java/dev/slne/surf/surfapi/core/api/invoker/InvokerClassData.java new file mode 100644 index 000000000..526127f1e --- /dev/null +++ b/surf-api-core/surf-api-core-api/src/main/java/dev/slne/surf/surfapi/core/api/invoker/InvokerClassData.java @@ -0,0 +1,30 @@ +package dev.slne.surf.surfapi.core.api.invoker; + +import dev.slne.surf.surfapi.shared.api.util.InternalInvokerApi; +import org.jetbrains.annotations.ApiStatus; +import org.jspecify.annotations.NullMarked; + +import java.lang.invoke.MethodHandle; +import java.lang.reflect.Method; + +/** + * Immutable carrier for the resolved class data of a hidden invoker class. + * + *

Unpacked from the hidden class's static initializer via + * {@link HiddenInvokerUtil#loadClassData}. + * + * @param method the original handler method + * @param methodHandle the resolved and bound MethodHandle for the handler + * @param payloadClass the concrete payload class the handler accepts + * @param isSuspend whether the original handler method is a Kotlin suspend function + */ +@NullMarked +@InternalInvokerApi +@ApiStatus.Internal +public record InvokerClassData( + Method method, + MethodHandle methodHandle, + Class payloadClass, + boolean isSuspend +) { +} \ No newline at end of file diff --git a/surf-api-core/surf-api-core-api/src/main/java/dev/slne/surf/surfapi/core/api/invoker/InvokerFactory.java b/surf-api-core/surf-api-core-api/src/main/java/dev/slne/surf/surfapi/core/api/invoker/InvokerFactory.java new file mode 100644 index 000000000..bd45f6351 --- /dev/null +++ b/surf-api-core/surf-api-core-api/src/main/java/dev/slne/surf/surfapi/core/api/invoker/InvokerFactory.java @@ -0,0 +1,133 @@ +package dev.slne.surf.surfapi.core.api.invoker; + +import dev.slne.surf.surfapi.shared.api.util.InternalInvokerApi; +import org.jetbrains.annotations.ApiStatus; +import org.jspecify.annotations.NullMarked; + +import java.io.IOException; +import java.io.InputStream; +import java.lang.invoke.MethodHandles; +import java.lang.reflect.Method; +import java.util.Objects; + +/** + * Generic factory for creating hidden-class-backed invoker instances. + * + *

Consumers subclass this (or use it directly) by providing: + *

    + *
  • The template class (whose bytecode is read at construction time)
  • + *
  • The invoker interface the hidden class will implement
  • + *
+ * + *

This replaces the per-project copy-paste of RedisEventInvokerFactory, + * RedisRequestHandlerInvokerFactory, RabbitListenerHandlerFactory, etc. + * + *

Usage

+ *
{@code
+ * // In surf-redis:
+ * var factory = new InvokerFactory<>(
+ *     RedisEventInvokerTemplate.class,
+ *     RedisEventInvoker.class
+ * );
+ * RedisEventInvoker invoker = factory.create(listener, method, MyEvent.class);
+ * }
+ * + * @param the invoker interface type + */ +@NullMarked +@InternalInvokerApi +@ApiStatus.Internal +public class InvokerFactory { + + private final byte[] templateClassBytes; + private final Class invokerInterface; + private final MethodHandles.Lookup lookup; + + /** + * Creates a new InvokerFactory. + * + * @param templateClass the template class whose .class bytecode will be used + * as the hidden class template + * @param invokerInterface the interface the generated hidden classes will implement + */ + public InvokerFactory(final Class templateClass, final Class invokerInterface) { + this(templateClass, invokerInterface, MethodHandles.lookup()); + } + + /** + * Creates a new InvokerFactory with a custom lookup. + * + * @param templateClass the template class + * @param invokerInterface the invoker interface + * @param lookup the lookup to use for defining hidden classes + */ + public InvokerFactory(final Class templateClass, final Class invokerInterface, final MethodHandles.Lookup lookup) { + this.invokerInterface = invokerInterface; + this.lookup = lookup; + + try (final InputStream is = templateClass.getResourceAsStream(templateClass.getSimpleName() + ".class")) { + Objects.requireNonNull(is, templateClass.getSimpleName() + ".class not found"); + this.templateClassBytes = is.readAllBytes(); + } catch (IOException e) { + throw new AssertionError("Failed to load " + templateClass.getSimpleName() + ".class", e); + } + } + + /** + * Checks whether a hidden class invoker can be created for the given target and method. + */ + public boolean canAccess(final Object target, final Method method) { + return HiddenInvokerUtil.canAccess(target, method, lookup); + } + + /** + * Creates a new invoker for the given target, method, and payload class. + * Automatically detects whether the method is a suspend function. + * + * @param target the listener/handler instance + * @param method the handler method + * @param payloadClass the concrete payload class the handler accepts + * @return a hidden-class-backed invoker + * @throws AssertionError if hidden class creation fails + */ + public I create(final Object target, final Method method, final Class payloadClass) { + try { + return HiddenInvokerUtil.createInvoker(lookup, templateClassBytes, invokerInterface, target, method, payloadClass); + } catch (ReflectiveOperationException e) { + throw new AssertionError("Failed to create " + invokerInterface.getSimpleName() + " for " + method, e); + } + } + + /** + * Creates a new invoker, explicitly specifying whether suspend is allowed. + * + * @param target the listener/handler instance + * @param method the handler method + * @param payloadClass the concrete payload class + * @param allowSuspend if false, treats the method as non-suspend even if it has a Continuation param + * @return a hidden-class-backed invoker + */ + public I create(final Object target, final Method method, final Class payloadClass, final boolean allowSuspend) { + try { + final boolean isSuspend = allowSuspend && HiddenInvokerUtil.isSuspendFunction(method); + return HiddenInvokerUtil.createInvoker(lookup, templateClassBytes, invokerInterface, target, method, payloadClass, isSuspend); + } catch (ReflectiveOperationException e) { + throw new AssertionError("Failed to create " + invokerInterface.getSimpleName() + " for " + method, e); + } + } + + /** + * Returns the pre-loaded template bytecode. + * Needed by template classes' static initializers. + */ + public byte[] getTemplateClassBytes() { + return templateClassBytes; + } + + /** + * Returns the lookup used for defining hidden classes. + */ + public MethodHandles.Lookup getLookup() { + return lookup; + } +} \ No newline at end of file diff --git a/surf-api-core/surf-api-core-api/src/main/kotlin/dev/slne/surf/surfapi/core/api/invoker/SuspendInvokerSupport.kt b/surf-api-core/surf-api-core-api/src/main/kotlin/dev/slne/surf/surfapi/core/api/invoker/SuspendInvokerSupport.kt new file mode 100644 index 000000000..f0f485a7c --- /dev/null +++ b/surf-api-core/surf-api-core-api/src/main/kotlin/dev/slne/surf/surfapi/core/api/invoker/SuspendInvokerSupport.kt @@ -0,0 +1,81 @@ +package dev.slne.surf.surfapi.core.api.invoker + +import dev.slne.surf.surfapi.shared.api.util.InternalInvokerApi +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.launch +import java.lang.invoke.MethodHandle +import kotlin.coroutines.CoroutineContext +import kotlin.coroutines.EmptyCoroutineContext +import kotlin.coroutines.intrinsics.COROUTINE_SUSPENDED +import kotlin.coroutines.intrinsics.suspendCoroutineUninterceptedOrReturn + +/** + * Utility for invoking suspend-function-backed MethodHandles from hidden class templates. + * + * When a handler method is a Kotlin `suspend fun`, the compiled method has an extra + * `Continuation` parameter and returns `Object` (possibly `COROUTINE_SUSPENDED`). + * + * This utility provides the bridge between the hidden class template (Java) and the + * Kotlin coroutine machinery. + */ +@InternalInvokerApi +object SuspendInvokerSupport { + + /** + * Invokes a suspend MethodHandle on the given CoroutineScope. + * + * The MethodHandle must have already been bound to the target instance and + * type-erased to accept (PayloadType, Continuation) -> Object. + * + * @param scope the CoroutineScope to launch the coroutine on + * @param methodHandle the bound MethodHandle for the suspend function + * @param payload the single argument to pass to the handler (the event/request/context) + * @param onError callback for errors (nullable, defaults to rethrowing) + */ + @JvmStatic + @JvmOverloads + fun invokeSuspend( + scope: CoroutineScope, + methodHandle: MethodHandle, + payload: Any, + onError: ((Throwable) -> Unit)? = null + ) { + scope.launch { + invokeSuspendDirect(methodHandle, payload, coroutineContext) + }.invokeOnCompletion { throwable -> + if (throwable != null) { + onError?.invoke(throwable) ?: throw throwable + } + } + } + + /** + * Directly invokes a suspend MethodHandle from within an existing coroutine. + * This is a suspend function itself, so it properly participates in structured concurrency. + * + * @param methodHandle the bound MethodHandle for the suspend function + * @param payload the argument to pass to the handler + * @param context the CoroutineContext (used for the Continuation) + */ + @JvmStatic + suspend fun invokeSuspendDirect( + methodHandle: MethodHandle, + payload: Any, + context: CoroutineContext = EmptyCoroutineContext + ) { + return suspendCoroutineUninterceptedOrReturn { cont -> + try { + val result = methodHandle.invoke(payload, cont) + if (result === COROUTINE_SUSPENDED) { + COROUTINE_SUSPENDED + } else { + // Handler completed synchronously (didn't actually suspend) + result + } + } catch (t: Throwable) { + cont.resumeWith(Result.failure(t)) + COROUTINE_SUSPENDED + } + } + } +} \ No newline at end of file diff --git a/surf-api-shared/surf-api-shared-public/api/surf-api-shared-public.api b/surf-api-shared/surf-api-shared-public/api/surf-api-shared-public.api index 1b2bbc57a..1b1072894 100644 --- a/surf-api-shared/surf-api-shared-public/api/surf-api-shared-public.api +++ b/surf-api-shared/surf-api-shared-public/api/surf-api-shared-public.api @@ -178,6 +178,9 @@ public abstract interface annotation class dev/slne/surf/surfapi/shared/api/comp public abstract interface annotation class dev/slne/surf/surfapi/shared/api/component/types/Service : java/lang/annotation/Annotation { } +public abstract interface annotation class dev/slne/surf/surfapi/shared/api/util/InternalInvokerApi : java/lang/annotation/Annotation { +} + public abstract interface annotation class dev/slne/surf/surfapi/shared/api/util/InternalSurfApi : java/lang/annotation/Annotation { } diff --git a/surf-api-shared/surf-api-shared-public/src/main/kotlin/dev/slne/surf/surfapi/shared/api/util/internal-api.kt b/surf-api-shared/surf-api-shared-public/src/main/kotlin/dev/slne/surf/surfapi/shared/api/util/internal-api.kt index d3c5ab2d2..a20e45a4b 100644 --- a/surf-api-shared/surf-api-shared-public/src/main/kotlin/dev/slne/surf/surfapi/shared/api/util/internal-api.kt +++ b/surf-api-shared/surf-api-shared-public/src/main/kotlin/dev/slne/surf/surfapi/shared/api/util/internal-api.kt @@ -7,4 +7,28 @@ import dev.slne.surf.surfapi.shared.api.annotation.InternalAPIMarker RequiresOptIn.Level.ERROR ) @InternalAPIMarker -annotation class InternalSurfApi \ No newline at end of file +annotation class InternalSurfApi + +/** + * Marks the hidden-class invoker infrastructure as internal and unstable. + * + * This API is intended **only** for surf-* plugins (surf-redis, surf-rabbitmq, etc.) + * that need high-performance, annotation-driven listener dispatch via JVM hidden classes. + * + * **No API stability guarantees apply.** Breaking changes may happen at any time. + * Consumer plugins must NOT depend on this API. + */ +@RequiresOptIn( + "This is internal invoker infrastructure for surf-* plugins. " + + "No API stability guarantees. Do not use from consumer plugins.", + RequiresOptIn.Level.ERROR +) +@InternalAPIMarker +@Retention(AnnotationRetention.BINARY) +@Target( + AnnotationTarget.CLASS, + AnnotationTarget.FUNCTION, + AnnotationTarget.PROPERTY, + AnnotationTarget.TYPEALIAS +) +annotation class InternalInvokerApi \ No newline at end of file From 9959658cb7d65aba54194ba810b711d3ff708d4e Mon Sep 17 00:00:00 2001 From: twisti <76837088+twisti-dev@users.noreply.github.com> Date: Fri, 27 Mar 2026 22:45:01 +0100 Subject: [PATCH 2/4] feat: expose HiddenInvokerUtil methods and add auto-suspend class data loading --- .../core/api/invoker/HiddenInvokerUtil.java | 31 ++++++++++++++++--- 1 file changed, 27 insertions(+), 4 deletions(-) diff --git a/surf-api-core/surf-api-core-api/src/main/java/dev/slne/surf/surfapi/core/api/invoker/HiddenInvokerUtil.java b/surf-api-core/surf-api-core-api/src/main/java/dev/slne/surf/surfapi/core/api/invoker/HiddenInvokerUtil.java index a49811d48..e4a30b37a 100644 --- a/surf-api-core/surf-api-core-api/src/main/java/dev/slne/surf/surfapi/core/api/invoker/HiddenInvokerUtil.java +++ b/surf-api-core/surf-api-core-api/src/main/java/dev/slne/surf/surfapi/core/api/invoker/HiddenInvokerUtil.java @@ -1,6 +1,8 @@ package dev.slne.surf.surfapi.core.api.invoker; +import dev.slne.surf.surfapi.shared.api.util.InternalInvokerApi; import kotlin.coroutines.Continuation; +import org.jetbrains.annotations.ApiStatus; import org.jspecify.annotations.NullMarked; import java.lang.constant.ConstantDescs; @@ -39,7 +41,9 @@ *

This class is package-private and not intended for external use. */ @NullMarked -final class HiddenInvokerUtil { +@InternalInvokerApi +@ApiStatus.Internal +public final class HiddenInvokerUtil { private HiddenInvokerUtil() { throw new UnsupportedOperationException(); @@ -52,7 +56,7 @@ private HiddenInvokerUtil() { * {@link Continuation} parameter as the last parameter, * and return {@link Object}. */ - static boolean isSuspendFunction(final Method method) { + public static boolean isSuspendFunction(final Method method) { final Class[] params = method.getParameterTypes(); if (params.length == 0) return false; @@ -152,7 +156,7 @@ static I createInvoker( * @return an {@link InvokerClassData} record * @throws ReflectiveOperationException if class data extraction or handle resolution fails */ - static InvokerClassData loadClassData( + public static InvokerClassData loadClassData( final MethodHandles.Lookup lookup, final MethodType methodType, final MethodType suspendMethodType @@ -173,13 +177,32 @@ static InvokerClassData loadClassData( * Overload for factories that don't support suspend (backward compatible). * Suspend methods will cause an error at template classData() time. */ - static InvokerClassData loadClassData( + public static InvokerClassData loadClassData( final MethodHandles.Lookup lookup, final MethodType methodType ) throws ReflectiveOperationException { return loadClassData(lookup, methodType, methodType); } + /** + * Loads the class data for a hidden invoker class, automatically configuring it for + * compatibility with Kotlin suspend functions, if applicable. + * + * @param lookup the {@link MethodHandles.Lookup} used for access checks and defining + * the hidden class. + * @param methodType the expected {@link MethodType} for non-suspend handler methods. + * @return an {@link InvokerClassData} instance containing the resolved handler method + * information, along with metadata on whether it's a suspend function. + * @throws ReflectiveOperationException if class data extraction or handle resolution fails. + */ + public static InvokerClassData loadClassDataWithAutoSuspend( + final MethodHandles.Lookup lookup, + final MethodType methodType + ) throws ReflectiveOperationException { + final MethodType suspendMethodType = methodType.changeReturnType(Object.class).appendParameterTypes(Continuation.class); + return loadClassData(lookup, methodType, suspendMethodType); + } + /** * Re-throws the given Throwable without requiring a checked exception declaration. */ From 795563bcd30ac778b92db6144242861452c04c07 Mon Sep 17 00:00:00 2001 From: twisti <76837088+twisti-dev@users.noreply.github.com> Date: Tue, 31 Mar 2026 11:59:31 +0200 Subject: [PATCH 3/4] feat(test-plugin): add shutdown action to clear player inventories and give diamonds on server stop --- .../surfapi/bukkit/test/BukkitPluginMain.kt | 33 +++++++++++++++++++ 1 file changed, 33 insertions(+) diff --git a/surf-api-bukkit/surf-api-bukkit-plugin-test/src/main/kotlin/dev/slne/surf/surfapi/bukkit/test/BukkitPluginMain.kt b/surf-api-bukkit/surf-api-bukkit-plugin-test/src/main/kotlin/dev/slne/surf/surfapi/bukkit/test/BukkitPluginMain.kt index e70bd0089..0959f7763 100644 --- a/surf-api-bukkit/surf-api-bukkit-plugin-test/src/main/kotlin/dev/slne/surf/surfapi/bukkit/test/BukkitPluginMain.kt +++ b/surf-api-bukkit/surf-api-bukkit-plugin-test/src/main/kotlin/dev/slne/surf/surfapi/bukkit/test/BukkitPluginMain.kt @@ -1,7 +1,10 @@ package dev.slne.surf.surfapi.bukkit.test +import com.destroystokyo.paper.event.server.ServerTickEndEvent +import com.destroystokyo.paper.event.server.ServerTickStartEvent import com.github.shynixn.mccoroutine.folia.SuspendingJavaPlugin import dev.jorel.commandapi.CommandAPI +import dev.slne.surf.surfapi.bukkit.api.event.listen import dev.slne.surf.surfapi.bukkit.api.inventory.framework.register import dev.slne.surf.surfapi.bukkit.api.nms.NmsUseWithCaution import dev.slne.surf.surfapi.bukkit.api.packet.listener.packetListenerApi @@ -14,6 +17,9 @@ import dev.slne.surf.surfapi.bukkit.test.config.ModernTestConfig import dev.slne.surf.surfapi.bukkit.test.config.MyPluginConfig import dev.slne.surf.surfapi.bukkit.test.listener.ChatListener import dev.slne.surf.surfapi.core.api.component.surfComponentApi +import net.minecraft.server.MinecraftServer +import org.bukkit.inventory.ItemType +import kotlin.concurrent.thread @OptIn(NmsUseWithCaution::class) class BukkitPluginMain : SuspendingJavaPlugin() { @@ -35,6 +41,33 @@ class BukkitPluginMain : SuspendingJavaPlugin() { MyPluginConfig.init() surfComponentApi.enable(this) + + fun runAction() { + for (player in server.onlinePlayers) { + player.scheduler.run(this@BukkitPluginMain, { + player.inventory.clear() + player.inventory.addItem(ItemType.DIAMOND.createItemStack(64)) + }, null) + } + } + + Runtime.getRuntime().addShutdownHook(thread(start = false) { + runAction() + }) + + listen { + if (!MinecraftServer.getServer().isRunning) { + print("Running action on shutdown in tick start event!") + runAction() + } + } + + listen { + if (!MinecraftServer.getServer().isRunning) { + print("Running action on shutdown in tick end event!") + runAction() + } + } } override suspend fun onDisableAsync() { From 190c1150854da23ba7c3214c587163ec4e518bbf Mon Sep 17 00:00:00 2001 From: twisti <76837088+twisti-dev@users.noreply.github.com> Date: Tue, 31 Mar 2026 14:01:35 +0200 Subject: [PATCH 4/4] chore: bumb abi --- surf-api-core/surf-api-core-api/api/surf-api-core-api.api | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/surf-api-core/surf-api-core-api/api/surf-api-core-api.api b/surf-api-core/surf-api-core-api/api/surf-api-core-api.api index 778026db8..fe39fa46d 100644 --- a/surf-api-core/surf-api-core-api/api/surf-api-core-api.api +++ b/surf-api-core/surf-api-core-api/api/surf-api-core-api.api @@ -6530,6 +6530,14 @@ public final class dev/slne/surf/surfapi/core/api/generated/VanillaAdvancementKe public static final field UPGRADE_TOOLS Lnet/kyori/adventure/key/Key; } +public final class dev/slne/surf/surfapi/core/api/invoker/HiddenInvokerUtil { + public static fun isSuspendFunction (Ljava/lang/reflect/Method;)Z + public static fun loadClassData (Ljava/lang/invoke/MethodHandles$Lookup;Ljava/lang/invoke/MethodType;)Ldev/slne/surf/surfapi/core/api/invoker/InvokerClassData; + public static fun loadClassData (Ljava/lang/invoke/MethodHandles$Lookup;Ljava/lang/invoke/MethodType;Ljava/lang/invoke/MethodType;)Ldev/slne/surf/surfapi/core/api/invoker/InvokerClassData; + public static fun loadClassDataWithAutoSuspend (Ljava/lang/invoke/MethodHandles$Lookup;Ljava/lang/invoke/MethodType;)Ldev/slne/surf/surfapi/core/api/invoker/InvokerClassData; + public static fun sneakyThrow (Ljava/lang/Throwable;)V +} + public final class dev/slne/surf/surfapi/core/api/invoker/InvokerClassData : java/lang/Record { public fun (Ljava/lang/reflect/Method;Ljava/lang/invoke/MethodHandle;Ljava/lang/Class;Z)V public final fun equals (Ljava/lang/Object;)Z