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
2 changes: 1 addition & 1 deletion gradle.properties
Original file line number Diff line number Diff line change
Expand Up @@ -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.72.1
version=1.21.11-2.73.0
relocationPrefix=dev.slne.surf.surfapi.libs
snapshot=false
Original file line number Diff line number Diff line change
@@ -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
Expand All @@ -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() {
Expand All @@ -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()
})
Comment on lines +45 to +56
Copy link

Copilot AI Mar 31, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This shutdown hook runs on a JVM shutdown thread and calls server.onlinePlayers, which is not guaranteed to be thread-safe during shutdown. If you need to mutate player inventories on shutdown, consider scheduling the work onto the appropriate server/region thread before shutdown completes (and avoid iterating Bukkit collections off-thread).

Copilot uses AI. Check for mistakes.
Comment on lines +54 to +56
Copy link

Copilot AI Mar 31, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Registering a JVM shutdown hook in onEnableAsync without removing it in onDisableAsync can lead to multiple hooks if the plugin is disabled/re-enabled in the same JVM (common during development/testing). Store the hook Thread and remove it on disable, or avoid the shutdown hook and rely on plugin lifecycle callbacks.

Copilot uses AI. Check for mistakes.

listen<ServerTickStartEvent> {
if (!MinecraftServer.getServer().isRunning) {
print("Running action on shutdown in tick start event!")
runAction()
}
}

listen<ServerTickEndEvent> {
if (!MinecraftServer.getServer().isRunning) {
print("Running action on shutdown in tick end event!")
runAction()
}
Comment on lines +58 to +69
Copy link

Copilot AI Mar 31, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Both tick listeners will call runAction() every tick once MinecraftServer.getServer().isRunning flips to false, which can repeatedly clear inventories/add items during shutdown (and also spams stdout via print). Gate this so the action runs at most once (e.g., an AtomicBoolean), and use the project logger instead of print for diagnostics.

Copilot uses AI. Check for mistakes.
}
}

override suspend fun onDisableAsync() {
Expand Down
38 changes: 38 additions & 0 deletions surf-api-core/surf-api-core-api/api/surf-api-core-api.api
Original file line number Diff line number Diff line change
Expand Up @@ -6530,6 +6530,44 @@ 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 <init> (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 <init> (Ljava/lang/Class;Ljava/lang/Class;)V
public fun <init> (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;
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,213 @@
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;
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.
*
* <p>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.).
*
* <h2>Hidden class lifecycle</h2>
* <ol>
* <li>{@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}.</li>
* <li>The hidden class's static initializer calls back to the factory's
* {@code classData()} method, which delegates to {@link #loadClassData}.</li>
* <li>{@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.</li>
* </ol>
*
* <h2>Suspend support</h2>
* <p>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).
*
* <p>This class is package-private and not intended for external use.
*/
@NullMarked
@InternalInvokerApi
@ApiStatus.Internal
public final class HiddenInvokerUtil {
Comment on lines +41 to +46
Copy link

Copilot AI Mar 31, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Javadoc says this class is package-private, but the class is declared public (and is part of the published API surface). Either make the class/package visibility match the intent, or update the documentation to describe the actual visibility and opt-in restrictions.

Copilot uses AI. Check for mistakes.

private HiddenInvokerUtil() {
throw new UnsupportedOperationException();
}

/**
* Checks whether the given method is a Kotlin suspend function.
*
* <p>Suspend functions are compiled with an additional
* {@link Continuation} parameter as the last parameter,
* and return {@link Object}.
*/
public 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);
Copy link

Copilot AI Mar 31, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

isSuspendFunction only checks for a trailing Continuation parameter, but the Javadoc also states suspend methods return Object. As-is, any non-suspend method that happens to accept a Continuation as its last parameter will be misclassified and the generated invoker will fail at runtime. Consider also validating method.getReturnType() == Object.class (and/or Kotlin metadata) to reduce false positives.

Suggested change
return Continuation.class.isAssignableFrom(lastParam);
return Continuation.class.isAssignableFrom(lastParam)
&& method.getReturnType() == Object.class;

Copilot uses AI. Check for mistakes.
}

/**
* 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);
Copy link

Copilot AI Mar 31, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

privateLookupIn is created against target.getClass(), but unreflect(method) requires access to method.getDeclaringClass(). If the handler method is declared on a superclass/interface (or otherwise not the target's runtime class), this access check may fail even when the method is otherwise invokable. Use method.getDeclaringClass() for privateLookupIn here.

Suggested change
MethodHandles.privateLookupIn(target.getClass(), lookup).unreflect(method);
final Class<?> declaringClass = method.getDeclaringClass();
MethodHandles.privateLookupIn(declaringClass, lookup).unreflect(method);

Copilot uses AI. Check for mistakes.
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.
*
* <p>The class data passed to the hidden class consists of:
* <ol>
* <li>The target handler/listener instance</li>
* <li>The payload class (event or request type)</li>
* <li>The handler {@link Method}</li>
* <li>A {@link MethodHandles.Lookup} with private access to the target's class</li>
* <li>A {@link Boolean} indicating whether the method is a suspend function</li>
* </ol>
*
* @param <I> 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> I createInvoker(
final MethodHandles.Lookup lookup,
final byte[] templateBytes,
final Class<I> 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> I createInvoker(
final MethodHandles.Lookup lookup,
final byte[] templateBytes,
final Class<I> 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<Object> classData = List.of(target, payloadClass, method, privateLookupIn, isSuspend);
final MethodHandles.Lookup hiddenClassLookup = lookup.defineHiddenClassWithClassData(templateBytes, classData, true);
Comment on lines +133 to +135
Copy link

Copilot AI Mar 31, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Hidden class creation stores a privateLookupIn created from target.getClass(). For correctness, the lookup should be created for method.getDeclaringClass() (otherwise loadClassData() may fail to unreflect(method) when the method isn’t declared directly on the runtime class).

Copilot uses AI. Check for mistakes.

return hiddenClassLookup.lookupClass()
.asSubclass(invokerInterface)
.getDeclaredConstructor()
.newInstance();
}

/**
* Extracts and resolves the class data that was passed to
* {@link MethodHandles.Lookup#defineHiddenClassWithClassData}.
*
* <p>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
*/
public 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.
*/
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.
*/
@SuppressWarnings("unchecked")
public static <T extends Throwable> void sneakyThrow(final Throwable t) throws T {
throw (T) t;
}
}
Original file line number Diff line number Diff line change
@@ -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.
*
* <p>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
) {
}
Loading
Loading