Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Asynchronous event handling with functional non-thread-blocking chunk loading [PROPOSAL] #129

Closed

Conversation

terminalsin
Copy link

@terminalsin terminalsin commented Jul 10, 2023

Asynchronous event handling proposal

What is this proposal? Well, as Folia keeps growing in popularity, so does the demand for its plugins. However, one conflicting issue regarding movement events has constantly been subject of controversy: getting chunks for positions included in the event.

This specific issue targets Teleport and dimensions changing events, notable the Teleport Event. This event is crucial for many reasons, and as such it is important to consider it.

0. Roadmap

  • Support basic asynchronous calls in events
  • Support nested asynchronous calls in events
  • Support and add chaining system for events called in listeners to not mix asynchronous calls (wait for completion of fired event before finishing self)
  • Support property listener hierarchy with synchronous despite asynchronous calls (if Listener 2 calls getChunkAtAsync, wait for that to resolve before firing up Listener 2 without any thread blocking)
  • Partially support callEvent and deprecate api
  • Fix handling to revert back to a task on original thread as opposed to asynchronous from last remaining thread
  • Fix bug where completed futures will persist indefinitiely, causing a memory leak
  • Fix bug where calling the methods teleportAsync or getChunkAsync from outside a ticked chunk causes issues. To properly fix, make any calls to these in threads besides region threads be ignored.

Event implementation

  • PlayerTeleportEvent
  • PlayerSpawnLocationEvent,
  • EntityPortalEnterEvent,
  • EntityPortalExitEvent,
  • PlayerPortalEvent,
  • EntityPortalReadyEvent

1. Philosophy

The main philosophy and optic I am aiming towards can be summarised in three points:

  1. Don't touch the Paper Bukkit API
  2. Don't add stuff
  3. Don't remove stuff

With all of this in mind, how can we effectively allow users to use functions as such:

@EventHandler
public void onTeleport(final PlayerTeleportEvent event) {
        System.out.println("Called event");
        event.getPlayer().getWorld().getChunkAtAsync(event.getTo().add(100, 0, 100)).thenRun(() -> {
            System.out.println("Chunk loaded");

            event.getTo().set(100, 100, 100);
        });
}

With code as above, well, since the event is asynchronous, the following flow will happen:

Race condition ![Untitled-2023-07-11-0416]()

But we don't want this! We want this:

Desired computation

Problem: How can we "guess" when users are getting asynchronous tasks from the server and how can we make sure we always get the end result.

Well, turns out, we are the server, and CompletableFutures are amazing.

2. Proposed Solution

To circumvent these issues, we must listen to every single endpoint which requests our chunks asynchronously. Lucky enough for us, it's literally one function.

Hence, the flow goes like this:

  1. Event is fired, creates a new list of completable futures it waits and listens for
  2. Chunk is requested, completable future is added to the list of completable futures it waits for
    a. If another event is fired, the current event waits for the fired event to be completed. This maintains nested synchronousy
  3. Event reaches the end, closes the list of completable futures. When the chunk is finished, it calls the remove method which in turn resolves all the chunk loading
  4. Event now calls the server actionable data, which queues the chunks which need loading for the teleport and handles all the necessary changes asynchronously

3. Implementation

For this implementation, we add the following class:

EventTaskHandler.java

public class EventTaskHandler {
    private final Stack<EventObserverList> eventTasks;

    public EventTaskHandler() {
        this.eventTasks = new Stack<>();
    }

    public CompletableFuture<Void> add(final EventObserverList list) {
        final CompletableFuture<Void> completedHandler = new CompletableFuture<>();

        final Runnable oldComplete = list.getRunnable();
        list.setRunnable(() -> {
            oldComplete.run();
            completedHandler.complete(null);
        });
        this.eventTasks.push(list);

        return completedHandler;
    }

    public EventObserverList pop() {
        return this.eventTasks.pop();
    }

    public EventObserverList peek() {
        if (isEmpty()) {
            throw new IllegalStateException("No event tasks are currently running");
        }

        return this.eventTasks.peek();
    }

    public void peekAndAdd(final EventObserverList future) {
        if (!isEmpty()) {
            peek().add(add(future));
        } else {
            add(future);
        }
    }

    public boolean isEmpty() {
        return this.eventTasks.isEmpty();
    }


    public static class EventObserverList {
        private MultiThreadedQueue<CompletableFuture<?>> queue = new MultiThreadedQueue<>();

        private Runnable onComplete;
        private boolean isLocked = true;

        public EventObserverList(Runnable onComplete) {
            this.onComplete = onComplete;
        }

        public void add(CompletableFuture<?> future) {
            queue.add(future);
        }

        public boolean remove(Object o) {
            final boolean output = queue.remove(o);
            this.checkIfEmptyAndUnlocked();
            return output;
        }

        public void clear() {
            queue.clear();
            this.checkIfEmptyAndUnlocked();
        }

        public void checkIfEmptyAndUnlocked() {
            if (!isLocked && queue.isEmpty() && this.onComplete != null) {
                this.onComplete.run();
            }
        }

        public void close() {
            this.isLocked = false;
            this.checkIfEmptyAndUnlocked();
        }

        public Runnable getRunnable() {
            return onComplete;
        }

        public void setRunnable(Runnable onComplete) {
            this.onComplete = onComplete;
        }
    }
}

Additions to TickRegionScheduler.java

/**
     * Some context behind this strange method. When an event is called via a
     * task handler, this event is registered to the current thread's event handler.
     * <p>
     * All chunk get tasked to the current thread's event handler between the start of
     * the execution of the event and the end of the execution of the event are locked
     * into an array. Once all of these have been completed, the completable future is
     * handled, allowing for the mechanic to be completed.
     * <p>
     * This effectively allows for us to asynchronously wait for all the chunk tasks
     * in different threads without endangering basic mechanics.
     *
     * @param event The event being called
     * @param runnable The runnable to be executed before the next listener may proceed
     * @return The completable future to be completed
     */
    public static CompletableFuture<Event> createEventTask(final Event event, final Runnable runnable) {
        final Thread currThread = Thread.currentThread();
        if (!(currThread instanceof TickThreadRunner tickThreadRunner)) {
            throw new IllegalStateException("Must be tick thread runner");
        }

        final CompletableFuture<Event> nextTask = new CompletableFuture<>();
        tickThreadRunner.eventHandler.peekAndAdd(
            new EventTaskHandler.EventObserverList(() -> nextTask.complete(event))
        );
        // Run and fetch all the async data
        runnable.run();

        // Close the runner and let it complete, then be garbage disposed
        tickThreadRunner.eventHandler.pop().close();

        // Return the completable future
        return nextTask;
    }

    public static <T> CompletableFuture<T> addEventListen(final CompletableFuture<T> task) {
        final Thread currThread = Thread.currentThread();
        if (!(currThread instanceof TickThreadRunner tickThreadRunner)) {
            throw new IllegalStateException("Must be tick thread runner");
        }

        if (tickThreadRunner.eventHandler.isEmpty())
            return task;

        final EventTaskHandler.EventObserverList observer = tickThreadRunner.eventHandler.peek();
        observer.add(task);

        return task.whenComplete((ignored, throwable) -> {
            if (throwable != null) {
                LOGGER.error("Exception in event task", throwable);
            }
            observer.remove(task);
        });
    }

PaperEventManager.java

class PaperEventManager {

    private final Server server;

    public PaperEventManager(Server server) {
        this.server = server;
    }

    public void callEvent(@NotNull Event event) {
        callEventAsync(event);
    }

    // SimplePluginManager
    public CompletableFuture<Event> callEventAsync(@NotNull Event event) {
        if (event.isAsynchronous() && this.server.isPrimaryThread()) {
            throw new IllegalStateException(event.getEventName() + " may only be triggered asynchronously.");
        } else if (!event.isAsynchronous() && !this.server.isPrimaryThread() && !this.server.isStopping()) {
            throw new IllegalStateException(event.getEventName() + " may only be triggered synchronously.");
        }

        HandlerList handlers = event.getHandlers();
        RegisteredListener[] listeners = handlers.getRegisteredListeners();

        final Stack<RegisteredListener> listenerStack = new Stack<>();
        for (int i = listeners.length - 1; i >= 0; i--) {
            listenerStack.push(listeners[i]);
        }

        return dequeueEvent(listenerStack, event);
    }

    private CompletableFuture<Event> dequeueEvent(final Stack<RegisteredListener> stack, final Event event) {
        if (stack.isEmpty())
            return CompletableFuture.completedFuture(event);

        final RegisteredListener registration = stack.pop();

        if (!registration.getPlugin().isEnabled()) {
            return dequeueEvent(stack, event);
        }

        return TickRegionScheduler.createEventTask(event, () -> {
            try {
                registration.callEvent(event);
            } catch (AuthorNagException ex) {
                Plugin plugin = registration.getPlugin();

                if (plugin.isNaggable()) {
                    plugin.setNaggable(false);

                    this.server.getLogger().log(Level.SEVERE, String.format(
                        "Nag author(s): '%s' of '%s' about the following: %s",
                        plugin.getPluginMeta().getAuthors(),
                        plugin.getPluginMeta().getDisplayName(),
                        ex.getMessage()
                    ));
                }
            } catch (Throwable ex) {
                String msg = "Could not pass event " + event.getEventName() + " to " + registration.getPlugin().getPluginMeta().getDisplayName();
                this.server.getLogger().log(Level.SEVERE, msg, ex);
                if (!(event instanceof ServerExceptionEvent)) { // We don't want to cause an endless event loop
                    this.callEvent(new ServerExceptionEvent(new ServerEventException(msg, ex, registration.getPlugin(), registration.getListener(), event)));
                }
            }
        }).thenCompose(e -> dequeueEvent(stack, e));
    }

    public void registerEvents(@NotNull Listener listener, @NotNull Plugin plugin) {
        if (!plugin.isEnabled()) {
            throw new IllegalPluginAccessException("Plugin attempted to register " + listener + " while not enabled");
        }

        for (Map.Entry<Class<? extends Event>, Set<RegisteredListener>> entry : this.createRegisteredListeners(listener, plugin).entrySet()) {
            this.getEventListeners(this.getRegistrationClass(entry.getKey())).registerAll(entry.getValue());
        }

    }

    public void registerEvent(@NotNull Class<? extends Event> event, @NotNull Listener listener, @NotNull EventPriority priority, @NotNull EventExecutor executor, @NotNull Plugin plugin) {
        this.registerEvent(event, listener, priority, executor, plugin, false);
    }

    public void registerEvent(@NotNull Class<? extends Event> event, @NotNull Listener listener, @NotNull EventPriority priority, @NotNull EventExecutor executor, @NotNull Plugin plugin, boolean ignoreCancelled) {
        if (!plugin.isEnabled()) {
            throw new IllegalPluginAccessException("Plugin attempted to register " + event + " while not enabled");
        }

        executor = new TimedEventExecutor(executor, plugin, null, event);
        this.getEventListeners(event).register(new RegisteredListener(listener, executor, priority, plugin, ignoreCancelled));
    }

    @NotNull
    private HandlerList getEventListeners(@NotNull Class<? extends Event> type) {
        try {
            Method method = this.getRegistrationClass(type).getDeclaredMethod("getHandlerList");
            method.setAccessible(true);
            return (HandlerList) method.invoke(null);
        } catch (Exception e) {
            throw new IllegalPluginAccessException(e.toString());
        }
    }

    @NotNull
    private Class<? extends Event> getRegistrationClass(@NotNull Class<? extends Event> clazz) {
        try {
            clazz.getDeclaredMethod("getHandlerList");
            return clazz;
        } catch (NoSuchMethodException e) {
            if (clazz.getSuperclass() != null
                && !clazz.getSuperclass().equals(Event.class)
                && Event.class.isAssignableFrom(clazz.getSuperclass())) {
                return this.getRegistrationClass(clazz.getSuperclass().asSubclass(Event.class));
            } else {
                throw new IllegalPluginAccessException("Unable to find handler list for event " + clazz.getName() + ". Static getHandlerList method required!");
            }
        }
    }

    // JavaPluginLoader
    @NotNull
    public Map<Class<? extends Event>, Set<RegisteredListener>> createRegisteredListeners(@NotNull Listener listener, @NotNull final Plugin plugin) {
        Map<Class<? extends Event>, Set<RegisteredListener>> ret = new HashMap<>();

        Set<Method> methods;
        try {
            Class<?> listenerClazz = listener.getClass();
            methods = Sets.union(
                Set.of(listenerClazz.getMethods()),
                Set.of(listenerClazz.getDeclaredMethods())
            );
        } catch (NoClassDefFoundError e) {
            plugin.getLogger().severe("Failed to register events for " + listener.getClass() + " because " + e.getMessage() + " does not exist.");
            return ret;
        }

        for (final Method method : methods) {
            final EventHandler eh = method.getAnnotation(EventHandler.class);
            if (eh == null) continue;
            // Do not register bridge or synthetic methods to avoid event duplication
            // Fixes SPIGOT-893
            if (method.isBridge() || method.isSynthetic()) {
                continue;
            }
            final Class<?> checkClass;
            if (method.getParameterTypes().length != 1 || !Event.class.isAssignableFrom(checkClass = method.getParameterTypes()[0])) {
                plugin.getLogger().severe(plugin.getPluginMeta().getDisplayName() + " attempted to register an invalid EventHandler method signature \"" + method.toGenericString() + "\" in " + listener.getClass());
                continue;
            }
            final Class<? extends Event> eventClass = checkClass.asSubclass(Event.class);
            method.setAccessible(true);
            Set<RegisteredListener> eventSet = ret.computeIfAbsent(eventClass, k -> new HashSet<>());

            for (Class<?> clazz = eventClass; Event.class.isAssignableFrom(clazz); clazz = clazz.getSuperclass()) {
                // This loop checks for extending deprecated events
                if (clazz.getAnnotation(Deprecated.class) != null) {
                    Warning warning = clazz.getAnnotation(Warning.class);
                    Warning.WarningState warningState = this.server.getWarningState();
                    if (!warningState.printFor(warning)) {
                        break;
                    }
                    plugin.getLogger().log(
                        Level.WARNING,
                        String.format(
                            "\"%s\" has registered a listener for %s on method \"%s\", but the event is Deprecated. \"%s\"; please notify the authors %s.",
                            plugin.getPluginMeta().getDisplayName(),
                            clazz.getName(),
                            method.toGenericString(),
                            (warning != null && warning.reason().length() != 0) ? warning.reason() : "Server performance will be affected",
                            Arrays.toString(plugin.getPluginMeta().getAuthors().toArray())),
                        warningState == Warning.WarningState.ON ? new AuthorNagException(null) : null);
                    break;
                }
            }

            EventExecutor executor = new TimedEventExecutor(EventExecutor.create(method, eventClass), plugin, method, eventClass);
            eventSet.add(new RegisteredListener(listener, executor, eh.priority(), plugin, eh.ignoreCancelled()));
        }
        return ret;
    }

    public void clearEvents() {
        HandlerList.unregisterAll();
    }
}

Example event: PlayerTeleportEvent / EntityTeleportEvent
Many things to express. This change implements the EntityTeleportEvent in a context thread safe way. The following are handled:

  • Passengers have the event called properly
  • Recursion is prevented by checking for the teleport status. Such status is revoked by overriding and chaining the teleport callback. It is manually removed when the event fails and henceforth the callback would call null, or when the entities are in the same chunk region, and hence don't call the callback.
  • Teleporting status is removed on callback
  • Modifying the teleport location of passenger entities is forbidden to prevent conflicts
  • Cancelling an entity riding will dismount it and NOT trigger any events for its passengers
Modified Entity.java class

public final boolean teleportAsync(ServerLevel destination, Vec3 ogPos, Float ogYaw, Float ogPitch, Vec3 speedDirectionUpdate,
                                       org.bukkit.event.player.PlayerTeleportEvent.TeleportCause cause, long teleportFlags,
                                       java.util.function.Consumer<Entity> teleportComplete) {
        io.papermc.paper.util.TickThread.ensureTickThread(this, "Cannot teleport entity async");

        if (!ServerLevel.isInSpawnableBounds(new BlockPos(
            io.papermc.paper.util.CoordinateUtils.getBlockX(ogPos),
            io.papermc.paper.util.CoordinateUtils.getBlockY(ogPos),
            io.papermc.paper.util.CoordinateUtils.getBlockZ(ogPos)))) {
            return false;
        }

        if (!this.canTeleportAsync()) {
            return false;
        }
        org.bukkit.entity.Entity bukkitEntity = this.getBukkitEntity(); // force bukkit entity to be created before TPing
        if ((teleportFlags & TELEPORT_FLAG_UNMOUNT) == 0L) {
            for (Entity entity : this.getIndirectPassengers()) {
                if (!entity.canTeleportAsync()) {
                    return false;
                }
                entity.getBukkitEntity(); // force bukkit entity to be created before TPing
            }
        } else {
            this.unRide();
        }

        if ((teleportFlags & TELEPORT_FLAG_TELEPORT_PASSENGERS) != 0L) {
            if (this.isPassenger()) {
                return false;
            }
        } else {
            if (this.isVehicle() || this.isPassenger()) {
                return false;
            }
        }

        // If teleport is successful, set the teleporting status BEFORE calling the callback. This
        // effectively prevents, as seen above, from recursive teleportation, but allows for chained
        // teleportation.

        // TODO:    This is a hack to allow for chained teleportation. There is an important need for
        //          an api change to support culling, returning back what entity was attempted to be
        //          teleported and what the status of such teleportation attempt was
        //
        //          eg:
        //          TeleportOutput (
        //              Entity entity,
        //              ResultType resultType,
        //
        //              isSuccees() -> boolean [resultType == SUCCESS],
        //              isFailed() -> boolean [resultType == CANCELLED || resultType == CANCELLED_VEHICLE || resultType == ERROR]
        //          )
        //
        //          ResultType (SUCCESS, CANCELLED, CANCELLED_VEHICLE, ERROR)
        //
        final java.util.function.Consumer<Entity> previousConsumer = teleportComplete;
        teleportComplete = entity -> {
            if (entity != null) {
                final EntityTreeNode tree = entity.detachPassengers();
                for (final EntityTreeNode entityNode : tree.getFullTree()) {
                    entityNode.root.setTeleporting(false);
                }
            }

            previousConsumer.accept(entity);
        };

        // Grab original location
        final Location ogFrom = this.getBukkitEntity().getLocation();
        final Location ogTo =  new Location(
            destination.getWorld(),
            ogPos == null ? this.getX() : ogPos.x,
            ogPos == null ? this.getX() : ogPos.y,
            ogPos == null ? this.getX() : ogPos.z,
            ogYaw == null ? bukkitEntity.getLocation().getYaw() : ogYaw,
            ogPitch == null ? bukkitEntity.getLocation().getPitch() : ogPitch
        );

        // Special event exclusively for players.
        final Consumer<Entity> finalTeleportComplete = teleportComplete;
        this.createBukkitTeleport(
            ogFrom,
            ogTo,
            cause,
            teleportComplete
        ).callAsync().thenAccept(teleportEvent -> {
            final Location to;
            if (this instanceof Player) {
                to = ((PlayerTeleportEvent) teleportEvent).getTo();
            } else {
                to = ((EntityTeleportEvent) teleportEvent).getTo();
            }

            if (to == null) {
                return; // TODO: Make teleportAsync return an async result
            }

            // Update the events accordingly.
            Vec3 pos = new Vec3(
                to.getX(),
                to.getY(),
                to.getZ()
            );
            float yaw = to.getYaw();
            float pitch = to.getPitch();

            // End of event handling
            Runnable executor = () -> {
                // check for same region
                if (destination == this.level()) {
                    Vec3 currPos = this.position();
                    if (
                        destination.regioniser.getRegionAtUnsynchronised(
                            io.papermc.paper.util.CoordinateUtils.getChunkX(currPos), io.papermc.paper.util.CoordinateUtils.getChunkZ(currPos)
                        ) == destination.regioniser.getRegionAtUnsynchronised(
                            io.papermc.paper.util.CoordinateUtils.getChunkX(pos), io.papermc.paper.util.CoordinateUtils.getChunkZ(pos)
                        )
                    ) {
                        EntityTreeNode passengerTree = this.detachPassengers();

                        // Note: The client does not accept position updates for controlled entities. So, we must
                        // perform a lot of tracker updates here to make it all work out.

                        // first, clear the tracker
                        passengerTree.clearTracker();
                        for (EntityTreeNode entity : passengerTree.getFullTree()) {
                            entity.root.teleportSyncSameRegion(pos, yaw, pitch, speedDirectionUpdate);
                            entity.root.setTeleporting(false); // No callback, we must force this action
                        }

                        passengerTree.restore();
                        // re-add to the tracker once the tree is restored
                        passengerTree.addTracker();

                        // adjust entities to final position
                        passengerTree.adjustRiders(true);

                        // the tracker clear/add logic is only used in the same region, as the other logic
                        // performs add/remove from world logic which will also perform add/remove tracker logic

                        if (finalTeleportComplete != null) {
                            finalTeleportComplete.accept(this);
                        }
                    }
                }

                EntityTreeNode passengerTree = this.detachPassengers();

                List<EntityTreeNode> fullPassengerTree = passengerTree.getFullTree();
                ServerLevel originWorld = (ServerLevel)this.level;

                for (EntityTreeNode node : fullPassengerTree) {
                    node.root.preChangeDimension();
                }

                for (EntityTreeNode node : fullPassengerTree) {
                    node.root = node.root.transformForAsyncTeleport(destination, pos, yaw, pitch, speedDirectionUpdate);
                    node.root.setTeleporting(true); // hotfix
                }

                passengerTree.root.placeInAsync(originWorld, destination, teleportFlags, passengerTree, finalTeleportComplete);
            };

            if (!this.getPassengers().isEmpty()) {
                final EntityTreeNode passengerTree = this.detachPassengers();

                // Failsafe
                if (passengerTree.passengers == null) {
                    throw new IllegalStateException("Passenger tree root must not be null");
                }

                // First handle all events
                // This is a DFS. Here's one issue one may think of. Let's assume we have three entities.
                // We have Player 1, Player 2, and Player 3. Player 1 is riding Player 2, and
                // Player 2 is riding Player 3. If we teleport Player 1, we must teleport Player 2
                // But let's assume player 1 cancels the event, then he will be dismounted from player 2.
                // But what if player 2 also cancels the event? Then player 2 will be dismounted from player 3
                //
                // This is why we must perform a BFS correctly and manually. This also improves redundant
                // teleport calls for entities that won't be teleported anyway
                final Stack<EntityTreeNode> entityStack = new Stack<>();
                entityStack.addAll(Arrays.asList(passengerTree.passengers)); // Add all passengers

                final Runnable lastExecutor = executor;
                executor = () -> {
                    this.handlePassengerTeleport(
                        entityStack,
                        to,
                        cause,
                        entity -> {}
                    ).thenRun(lastExecutor);
                };
            }
            executor.run();
        });

        return true;
    }

    private CompletableFuture<Void> handlePassengerTeleport(final Stack<EntityTreeNode> entityStack,
                                                            final Location to,
                                                            final PlayerTeleportEvent.TeleportCause cause,
                                                            final Consumer<Entity> teleportComplete) {
        if (entityStack.isEmpty()) {
            return CompletableFuture.completedFuture(null);
        }

        final EntityTreeNode entityNode = entityStack.pop();
        final Entity entity = entityNode.root;
        final org.bukkit.entity.Entity bukkitPassengerEntity = entity.getBukkitEntity();

        // TODO:    Temporary whilst api changes are discussed and implemented
        //          The property teleporting is removed due to entity nbt data
        //          being saved and loaded.
        entity.setTeleporting(true);

        // Teleport action
        // Must perform clone as location is mutable. Make sure to prevent the location from modified
        final Location previous = to.clone();

        return entity.createBukkitTeleport(
                bukkitPassengerEntity.getLocation(),
                to,
                cause,
                teleportComplete
        ).callAsync().thenAccept(teleportEvent -> {
            Location entityTo;
            if (((Cancellable) teleportEvent).isCancelled()) {
                entity.stopRiding(true); // Suppress cancel, we must force this action
                entity.setTeleporting(false);
                return;
            } else {
                if (this instanceof Player) {
                    entityTo = ((PlayerTeleportEvent) teleportEvent).getTo();
                } else {
                    entityTo = ((EntityTeleportEvent) teleportEvent).getTo();
                }
            }

            // Failsafe
            if (entityTo == null) {
                throw new IllegalStateException(
                    "EntityTeleportEvent#getTo() returned null - This is illegal behaviour. Please instead cancel the event."
                );
            }

            // Prevent riding entities from changing the destination location
            if (!previous.equals(to)) {
                throw new UnsupportedOperationException("Teleporting entities whilst they are riding another entity is not supported");
            }

            // Add all passengers
            if (entityNode.passengers != null) {
                entityStack.addAll(Arrays.asList(entityNode.passengers));
            }
        }).thenRun(() -> handlePassengerTeleport(entityStack, to, cause, teleportComplete));
    }

    protected @Nullable Event createBukkitTeleport(Location from, Location to, PlayerTeleportEvent.TeleportCause cause,
                                                   Consumer<Entity> teleportComplete) {
        org.bukkit.entity.Entity bukkitEntity = this.getBukkitEntity(); // force bukkit entity to be created before TPing

        // Prevent recursive action. Teleports should
        if (this.isTeleporting()) {
            throw new IllegalStateException(
                "Entity is already teleporting. Please do not teleport an entity inside a EntityTeleportEvent listener."
            );
        }

        // --- Event handling
        this.setTeleporting(true);

        if (this instanceof Player) {
            return new PlayerTeleportEvent(
                (org.bukkit.entity.Player) bukkitEntity,
                from,
                to,
                cause,
                new HashSet<>() // Unsupported by API currently.
            );
        } else {
            return new EntityTeleportEvent(
                bukkitEntity,
                from,
                to
            );
        }
    }

4 Proof of concept (?)

https://www.youtube.com/watch?v=dLGMj84XsSk

5. Drawbacks and unimplemented concepts

Obviously, as of right now, every listener is executed linearly and the result changes reflect the such. Except, this is bad practice. We want to follow an order for listeners... That's the point of Event driven architecture.

The changes required would effectively take the listeners and independently run them through in this system. Instead of clearing post event, we would clear and handle changes to the event after every listener. This would ensure linearity and would safeguard the current event system.

Nvm fixed lmao I can't think of one drawback besides nagging devs to properly update their apis to call events async if folia is activated

Many things to express. This change implements the EntityTeleportEvent in a context thread safe way. The following are handled:
- Passengers have the event called properly
- Recursion is prevented by checking for the teleport status. Such status is revoked by overriding and chaining the teleport callback. It is manually removed when the event fails and henceforth the callback would call null, or when the entities are in the same chunk region, and hence don't call the callback.
- Teleporting status is removed on callback
- Modifying the teleport location of passenger entities is forbidden to prevent conflicts
- Cancelling an entity riding will dismount it and NOT trigger any events for its passengers
@terminalsin terminalsin changed the title Implement EntityTeleportEvent properly Asynchronous event handling with functional non-thread-blocking chunk loading [PROPOSAL] Jul 11, 2023
… bukkit event api

- Reworked listeners. Now they execute synchronously after any edits including chunk loading. This allows for perfect asynchronous linear behaviour (tested!)
- Reworked bukkit event api, introduced callEventAsync. If old method is call, the event is at risk of not having the proper data if paired up with any kind of chunk loading or non-direct modifier which is called asynchronously. This is the best we could possibly do without creating conflicting behaviour
- Fixed teleportation whoopsie I made
…usly fetched thread context

- If the current thread is not a region --> run consecutive code on current thread
- If the origin of the event is unspecified --> heuristical approach at fetching current thread coords by taking a random coordinate in current map of thread. This can go wrong so easily, SpottedLeaf, this one's on you, I trust that you'll help me improve this
- If the origin of the event is specified -> return to thread context at specified location (hurray)

Additional improvements:
- Made nms command /teleport depend on bukkit handling to support proper event handling
- Added player teleportation to the handlers. All player teleports now have the same behaviour as chunk fetches (yipee)
- Fixed a scenario of chunk loading I forgot about
Implemented the following events:
-  PlayerSpawnLocationEvent,
-  EntityPortalEnterEvent,
-  EntityPortalExitEvent,
-  PlayerPortalEvent,
-  EntityPortalReadyEvent
@nuym
Copy link

nuym commented Jul 11, 2023

cool,a new anticheat?

@terminalsin
Copy link
Author

cool,a new anticheat?

touché

…hread and offthread events

- Fixed issue where global thread events wouldn't be properly accounted for
- Fixed an issue where the EntityEnterPortalEvent would be fired every tick (god knows how long this has been there like this)
@Q2297045667
Copy link

This is great

@terminalsin
Copy link
Author

@Spottedleaf If you ever get time to discuss this proposal lmk. I’d love to put more effort into it, although I’d like to preferably see some sort of proper nod up so my efforts don’t end up being trashed yknow :)

@sofianedjerbi
Copy link
Contributor

They may ask you to follow paper conventions (dont change nms file imports & proper comments)

@terminalsin
Copy link
Author

They may ask you to follow paper conventions (dont change nms file imports & proper comments)

Duly noted, will make the appropriate changes to follow these

- Fix non-region threads calling teleport and chunk functions failing 
- Fixed wrong origin level in player and entity portal event
@Spottedleaf
Copy link
Member

I think it's very important that if you are going to create a large PR that you do not write anything before having the concepts approved beforehand, as it is easiest to change things in design rather than in code. As it stands, the design concepts in this PR would not have been approved, which would have saved you a lot of effort on your part.

The reason the events for teleportation/spawning/other are absent is because the required implementation is requires an entirely different event system. I haven't had the time to dedicate to making one, or even to design one abstractly, and certainly not to write any documentation on the matter.

Considering the PR though, there are simply too many problems for it to be merged in its current state or even a modified state. These issues are as follows:

  • The existing event system needs to remain unchanged, as a new event system is required.
  • CompletableFutures are used, which is will cause problems with exception handling and debugging.
  • Hacking around CraftWorld#getChunkAtAsync and CraftEntity#teleportAsync isn't actually going to even allow you to properly
    capture those callbacks with respect to plugin logic. So effectively the implementation is broken. It is additionally also entirely unintuitive and may not be the desired behavior in all cases.

The new event system must allow listeners to decide when the next listener is invoked. This is to allow the listener to use any thread context it wants. However, this approach has numerous issues: plugins never forwarding to the next listener, plugins taking too long to forward to the next listener, and undefined thread context for which listeners are invoked. These issues are solvable, as it just needs proper design. I would suspect that bringing this system to Paper is desirable, which means that it would need additional approval of other Paper maintainers.

Additionally, the event system must not use CompletableFuture in any shape or form. This is due to CompletableFuture's exception handling being completely atrocious, and for general API it is not acceptable to have such poor exception handling. It is extremely easy to fall into a trap where exceptions are not handled or printed. This PR is a relevant example, as it does not perform proper exception handling (print, rethrow, or handle) in many areas. I have learned the hard way from the Vanilla chunk system that CompletableFuture is nothing but a complete pain when debugging needs to be done, and to this day I run into issues with CompletableFuture usage not printing exceptions. It is not worth using them to design a larger system.

Your hooks into getChunkAtAsync and teleportAsync are incorrect. The CompletableFuture returned by the original function is going to be completed when the chunk or entity is teleported. Then, your code will remove that future from the observer list and return a future that is completed after that is done. So, any plugin logic that hooks onto that future can only run after the observer is removed. This actually results in the plugin logic being delayed until after all of the remaining listeners are invoked - by that time, the chunk may be unloaded. More importantly, the thread context may have switched and operations on chunks by the wrong thread context will throw exceptions. And finally, it will violate the event firing order. As a result, while it may appear in very limited scenarios that your implementation works it is fundamentally flawed.

An additional note on CompletableFutures is that the execution context for callbacks is completely undefined. This is an additional reason that CompletableFuture is unacceptable to use, as it is has these gotchas on top of the debugging issues. Let's use the example in your code:

        event.getPlayer().getWorld().getChunkAtAsync(event.getTo().add(100, 0, 100)).thenRun(() -> {
            System.out.println("Chunk loaded");

            event.getTo().set(100, 100, 100);
        });

On what thread context does the Runnable passed to thenRun() run if we exclude the logic this PR adds? There are actually three answers:

  1. The chunk is already loaded synchronously and is invoked invoked synchronously to thenRun(), which can only happen while being on the correct region.
  2. The chunk is not loaded, then is loaded asynchronously and the callback is invoked asynchronously after being loaded, on the correct region which owns the chunk.
  3. The chunk is not loaded, then is loaded asynchronously and the callback is invoked synchronously to thenRun(), which is possibly on the wrong region.

The 3rd one is not immediately obvious when it happens and in fact only rarely happens. This is due to a race condition with the API design using CompletableFuture. The load is scheduled to happen asynchronously, which will then complete the returned future. However, it is possible for the future to be completed on another region before thenRun() is invoked. In such a case, thenRun() is invoked after the future is completed which will then invoke the Runnable synchronously.

As a result, a plugin using getChunkAtAsync cannot even guarantee that it can use the chunk unless it invokes getChunkAtAsync on the right thread context to begin with. This issue is entirely attributable to the use of CompletableFuture. The API should have never been designed to use CompletableFuture in the first place due to this behavior. But, we cannot just delete this API and so that's that.

As a result of these major issues, I will not be taking this PR in this form. I don't see what can be used from this PR to meet the requirements for the event system changes. Right now, this area of code is not a high priority for me to look into. I would consider the world loading stuff to be more important. Even then, the world loading stuff is an order of magnitude more complicated than this.

For larger PRs you should seek approval on the general design before writing anything to ensure you don't start writing code for something that wont be approved.

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.

None yet

5 participants