Skip to content

Implement hidden-class invoker infrastructure and utility methods#272

Merged
twisti-dev merged 5 commits intoversion/1.21.11from
feat/internal-invoker-api
Mar 31, 2026
Merged

Implement hidden-class invoker infrastructure and utility methods#272
twisti-dev merged 5 commits intoversion/1.21.11from
feat/internal-invoker-api

Conversation

@twisti-dev
Copy link
Copy Markdown
Contributor

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

  • Introduced 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.).
  • Added InvokerClassData, an immutable record that holds resolved data for a hidden invoker class, including the original method, bound MethodHandle, payload class, and suspend flag.
  • Added 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

  • Updated the core API's public surface to include the new invoker classes and methods, as reflected in the generated API file.
  • Bumped project version from 1.21.11-2.72.1 to 1.21.11-2.73.0 in gradle.properties.

Test Plugin Enhancements

  • Improved the test plugin (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.

@twisti-dev twisti-dev self-assigned this Mar 31, 2026
Copilot AI review requested due to automatic review settings March 31, 2026 12:08
@twisti-dev twisti-dev merged commit 1d7247c into version/1.21.11 Mar 31, 2026
4 of 5 checks passed
@twisti-dev twisti-dev deleted the feat/internal-invoker-api branch March 31, 2026 12:08
Copy link
Copy Markdown
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

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

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 @InternalInvokerApi to mark the invoker infrastructure as internal/unstable.
  • Updated the test plugin with shutdown/tick behavior and bumped gradle.properties version.

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.

Comment on lines +41 to +46
* <p>This class is package-private and not intended for external use.
*/
@NullMarked
@InternalInvokerApi
@ApiStatus.Internal
public final class HiddenInvokerUtil {
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.
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.
*/
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.
Comment on lines +133 to +135
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);
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.
Comment on lines +101 to +116
/**
* 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);
}
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.

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.

Copilot uses AI. Check for mistakes.
Comment on lines +44 to +47
invokeSuspendDirect(methodHandle, payload, coroutineContext)
}.invokeOnCompletion { throwable ->
if (throwable != null) {
onError?.invoke(throwable) ?: throw throwable
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.

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.

Suggested change
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
}

Copilot uses AI. Check for mistakes.
Comment on lines +61 to +65
suspend fun invokeSuspendDirect(
methodHandle: MethodHandle,
payload: Any,
context: CoroutineContext = EmptyCoroutineContext
) {
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.

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).

Copilot uses AI. Check for mistakes.
Comment on lines +45 to +56
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()
})
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 +58 to +69
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()
}
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.
Comment on lines +54 to +56
Runtime.getRuntime().addShutdownHook(thread(start = false) {
runAction()
})
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.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants