Implement hidden-class invoker infrastructure and utility methods#272
Implement hidden-class invoker infrastructure and utility methods#272twisti-dev merged 5 commits intoversion/1.21.11from
Conversation
…d give diamonds on server stop
# Conflicts: # gradle.properties
There was a problem hiding this comment.
Pull request overview
This PR adds a shared “hidden class” invoker infrastructure to surf-api-core-api (plus an opt-in marker in surf-api-shared-public) to unify dynamic handler invocation across surf-* plugins, including explicit Kotlin suspend handler support. It also bumps the project version and extends the Bukkit test plugin with shutdown/tick hooks.
Changes:
- Introduced hidden-class invoker utilities/factory (
HiddenInvokerUtil,InvokerFactory,InvokerClassData) and suspend invocation bridge (SuspendInvokerSupport). - Added a new opt-in annotation
@InternalInvokerApito mark the invoker infrastructure as internal/unstable. - Updated the test plugin with shutdown/tick behavior and bumped
gradle.propertiesversion.
Reviewed changes
Copilot reviewed 9 out of 9 changed files in this pull request and generated 11 comments.
Show a summary per file
| File | Description |
|---|---|
| surf-api-shared/surf-api-shared-public/src/main/kotlin/dev/slne/surf/surfapi/shared/api/util/internal-api.kt | Adds @InternalInvokerApi opt-in marker for hidden-invoker infrastructure. |
| surf-api-shared/surf-api-shared-public/api/surf-api-shared-public.api | Public API surface update for new annotation. |
| surf-api-core/surf-api-core-api/src/main/kotlin/dev/slne/surf/surfapi/core/api/invoker/SuspendInvokerSupport.kt | Adds coroutine bridge for invoking suspend-backed MethodHandles. |
| surf-api-core/surf-api-core-api/src/main/java/dev/slne/surf/surfapi/core/api/invoker/InvokerFactory.java | Adds generic factory to define hidden-class-backed invokers from a template. |
| surf-api-core/surf-api-core-api/src/main/java/dev/slne/surf/surfapi/core/api/invoker/InvokerClassData.java | Adds record carrying resolved handler method/handle metadata. |
| surf-api-core/surf-api-core-api/src/main/java/dev/slne/surf/surfapi/core/api/invoker/HiddenInvokerUtil.java | Adds core hidden-class definition + class-data unpacking utilities and suspend detection. |
| surf-api-core/surf-api-core-api/api/surf-api-core-api.api | Public API surface update for new invoker types. |
| surf-api-bukkit/surf-api-bukkit-plugin-test/src/main/kotlin/dev/slne/surf/surfapi/bukkit/test/BukkitPluginMain.kt | Adds shutdown hook + tick listeners to run inventory actions during shutdown. |
| gradle.properties | Version bump to 1.21.11-2.73.0. |
| * <p>This class is package-private and not intended for external use. | ||
| */ | ||
| @NullMarked | ||
| @InternalInvokerApi | ||
| @ApiStatus.Internal | ||
| public final class HiddenInvokerUtil { |
There was a problem hiding this comment.
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.
| if (params.length == 0) return false; | ||
|
|
||
| final Class<?> lastParam = params[params.length - 1]; | ||
| return Continuation.class.isAssignableFrom(lastParam); |
There was a problem hiding this comment.
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.
| return Continuation.class.isAssignableFrom(lastParam); | |
| return Continuation.class.isAssignableFrom(lastParam) | |
| && method.getReturnType() == Object.class; |
| */ | ||
| static boolean canAccess(final Object target, final Method method, final MethodHandles.Lookup lookup) { | ||
| try { | ||
| MethodHandles.privateLookupIn(target.getClass(), lookup).unreflect(method); |
There was a problem hiding this comment.
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.
| MethodHandles.privateLookupIn(target.getClass(), lookup).unreflect(method); | |
| final Class<?> declaringClass = method.getDeclaringClass(); | |
| MethodHandles.privateLookupIn(declaringClass, lookup).unreflect(method); |
| 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); |
There was a problem hiding this comment.
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).
| /** | ||
| * 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); | ||
| } |
There was a problem hiding this comment.
The allowSuspend=false branch forces isSuspend=false even when the method is actually a Kotlin suspend function. That will later make loadClassData() attempt to adapt a (payload, Continuation) -> Object handle to the non-suspend MethodType, which will fail at runtime. Instead, if allowSuspend is false and isSuspendFunction(method) is true, fail fast with a clear exception/message.
| invokeSuspendDirect(methodHandle, payload, coroutineContext) | ||
| }.invokeOnCompletion { throwable -> | ||
| if (throwable != null) { | ||
| onError?.invoke(throwable) ?: throw throwable |
There was a problem hiding this comment.
invokeSuspend rethrows exceptions from an invokeOnCompletion handler. Throwing from a completion handler doesn’t propagate back to the caller (and can create confusing secondary failures). Prefer handling exceptions inside the launched coroutine (try/catch around invokeSuspendDirect) and delegating to the scope’s CoroutineExceptionHandler when onError is null.
| invokeSuspendDirect(methodHandle, payload, coroutineContext) | |
| }.invokeOnCompletion { throwable -> | |
| if (throwable != null) { | |
| onError?.invoke(throwable) ?: throw throwable | |
| try { | |
| invokeSuspendDirect(methodHandle, payload, coroutineContext) | |
| } catch (t: Throwable) { | |
| if (onError != null) { | |
| onError(t) | |
| } else { | |
| throw t | |
| } |
| suspend fun invokeSuspendDirect( | ||
| methodHandle: MethodHandle, | ||
| payload: Any, | ||
| context: CoroutineContext = EmptyCoroutineContext | ||
| ) { |
There was a problem hiding this comment.
invokeSuspendDirect takes a context: CoroutineContext parameter but never uses it. This is misleading for callers and suggests the Continuation will be created with the provided context (it isn’t). Either remove the parameter or incorporate it (e.g., by creating an explicit Continuation with that context).
| 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() | ||
| }) |
There was a problem hiding this comment.
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).
| 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() | ||
| } |
There was a problem hiding this comment.
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.
| Runtime.getRuntime().addShutdownHook(thread(start = false) { | ||
| runAction() | ||
| }) |
There was a problem hiding this comment.
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.
This pull request introduces a new generic hidden-class-based invoker system to the core API, aimed at unifying and simplifying dynamic handler invocation across the codebase (such as for events, requests, and listeners). It adds reusable infrastructure for creating, managing, and introspecting hidden invoker classes, with explicit support for Kotlin suspend functions. Additionally, it includes a minor version bump and some test plugin changes.
Core API: Hidden Invoker Infrastructure
HiddenInvokerUtil, a utility class that encapsulates the JVM hidden class machinery for dynamic handler invocation, including suspend function detection and hidden class data management. This enables a unified approach to creating invoker classes for various handler types (events, requests, listeners, etc.).InvokerClassData, an immutable record that holds resolved data for a hidden invoker class, including the original method, boundMethodHandle, payload class, and suspend flag.InvokerFactory, a generic factory for creating hidden-class-backed invoker instances, replacing multiple per-project invoker factories with a single, reusable implementation. Supports both regular and suspend function handlers.API Surface and Versioning
1.21.11-2.72.1to1.21.11-2.73.0ingradle.properties.Test Plugin Enhancements
BukkitPluginMain) to add hooks for player inventory actions on server shutdown and tick events, demonstrating usage of the event system and scheduler. [1] [2] [3]These changes lay the groundwork for a more robust, maintainable, and feature-rich dynamic invocation system across the project.