Skip to content

Channel-based event API (v2)#21

Open
Axionize wants to merge 1 commit intomasterfrom
feat/event-channels-merge
Open

Channel-based event API (v2)#21
Axionize wants to merge 1 commit intomasterfrom
feat/event-channels-merge

Conversation

@Axionize
Copy link
Copy Markdown
Contributor

Picks up where #19 left off. Each event gets its own EventChannel
subclass that exposes a typed fire(user, check, verbose, ...) — the
hot path is a volatile read + direct method invoke, no per-fire
allocation, no reflection. The legacy bus.post(...) /
bus.subscribe(Class, listener) / @GrimEventHandler paths all keep
working as @Deprecated shims that route through each channel's legacy
slot.

Usage

GrimAbstractAPI api = GrimAPIProvider.get();
EventBus bus = api.getEventBus();
GrimPlugin grim = api.getGrimPlugin(this);   // resolve once at plugin enable

bus.get(FlagEvent.class).onFlag(grim, (user, check, verbose, cancelled) -> {
    if (shouldSuppress(user, check)) return true;   // cancel
    return cancelled;
});

Every typed subscribe takes a GrimPlugin so bus.unregisterAllListeners(grim)
sweeps it on disable, same shape as Bukkit's HandlerList. Plugin
authors that want to pass a platform-specific context (Bukkit
JavaPlugin, Fabric ModContainer, a Class) can use the
@Deprecated onX(Object ctx, Handler) overload that resolves through
the bus's extension manager — the warning is there to nudge toward
api.getGrimPlugin(this)-once-and-cache.

Hot path / caching

private static final FlagEvent.Channel FLAG =
    GrimAPIProvider.get().getEventBus().get(FlagEvent.class);

// later, on the netty thread:
FLAG.fire(user, check, verbose);

static final so the JIT folds the getfield. Check.flag() uses a
volatile lazy-init pattern instead to sidestep class-load-order worries
across check subclasses.

Priority ordering flips

Lower number fires first, higher gets the final say — Bukkit
EventPriority convention (LOWEST → LOW → NORMAL → HIGH → HIGHEST → MONITOR). Pre-1.3 Grim sorted highest-first. Plugins using default
priority (0) are unaffected; anyone with explicit numbers on
@GrimEventHandler / bus.subscribe(...) needs to re-evaluate.

No reflection on the dispatch path

OptimizedEventBus constructor news each channel explicitly:

installChannel(FlagEvent.class, new FlagEvent.Channel());
installChannel(CommandExecuteEvent.class, new CommandExecuteEvent.Channel());
// ... 9 more

So renaming FlagEvent.Channel breaks at compile time, not at runtime,
and ProGuard/tree-shakers keep everything reachable from the
constructor. bus.get(E.class) is a ConcurrentHashMap lookup keyed on
the Class identity. The only getDeclaredMethods / MethodHandle.invoke
live in the @Deprecated registerAnnotatedListeners path — opt-in, for
back-compat with plugins using @GrimEventHandler.

JMH

Benchmark                    (mode)  (subscribers)  Mode  Cnt    Score  Units
EventDispatchBenchmark.fire   typed              0  avgt    3    2.17  ns/op
EventDispatchBenchmark.fire   typed              1  avgt    3    7.19  ns/op
EventDispatchBenchmark.fire   typed              3  avgt    3   13.51  ns/op
EventDispatchBenchmark.fire  legacy              1  avgt    3   15.05  ns/op
EventDispatchBenchmark.fire  legacy              3  avgt    3   21.99  ns/op
EventDispatchBenchmark.fire   mixed              3  avgt    3   18.39  ns/op

With -prof gc: gc.count ≈ 0, gc.alloc.rate.norm ≈ 10⁻⁵ B/op — the
residual is JMH infrastructure noise, not dispatch overhead. Zero-alloc
fire confirmed across typed / legacy / mixed configurations.

Tests

25 unit tests in grim-internal/src/test:

  • priority ordering + typed/legacy mixed dispatch (11)
  • bus routing + reflective @GrimEventHandler + plugin-disable sweep (13)
  • 8 threads × 2000 fires per-thread legacy pool isolation (1)

Related

Grim-side migration PR: GrimAnticheat/Grim#

Rewrites event dispatch around per-event `EventChannel` subclasses so
plugins can subscribe to typed handlers that take the event's fields as
positional parameters instead of a wrapper object. The hot path is
volatile read + direct method invoke, no allocation, no reflection.

### New subscribe shape

```java
GrimAbstractAPI api = GrimAPIProvider.get();
EventBus bus = api.getEventBus();
GrimPlugin grim = api.getGrimPlugin(this);   // resolve once at plugin enable

bus.get(FlagEvent.class).onFlag(grim, (user, check, verbose, cancelled) -> {
    if (shouldSuppress(user, check)) return true;   // cancel
    return cancelled;                               // leave alone
});
```

Every subscribe is tied to a `GrimPlugin` — `bus.unregisterAllListeners(grim)`
sweeps every typed handler the same way Bukkit's `HandlerList` does. For
authors holding a Bukkit `JavaPlugin` / Fabric `ModContainer` / Class,
each channel also exposes a `@Deprecated onX(Object ctx, Handler, …)`
overload that resolves the context through the bus's extension manager.

### Hot-path caching

Fire sites cache the channel once:

```java
private static final FlagEvent.Channel FLAG =
    GrimAPIProvider.get().getEventBus().get(FlagEvent.class);

FLAG.fire(user, check, verbose);   // static-final → JIT folds the getfield
```

### Priority ordering

Lower number fires first, higher gets the final say — same direction as
Bukkit `EventPriority` (LOWEST → … → MONITOR). This is a break from
1.2.x which sorted highest-first. Default priority (0) users are
unaffected; callers with explicit numbers on `@GrimEventHandler` or the
deprecated class-keyed subscribe need to re-evaluate them.

### No reflection on the hot path

`OptimizedEventBus` constructor explicitly news up each channel:

```java
installChannel(FlagEvent.class, new FlagEvent.Channel());
installChannel(CommandExecuteEvent.class, new CommandExecuteEvent.Channel());
...
```

Obfuscator-safe — renaming `FlagEvent.Channel` breaks the constructor at
compile time, not at runtime. `bus.get(E.class)` is a ConcurrentHashMap
lookup keyed on the class identity. The only reflection is in the
`@Deprecated registerAnnotatedListeners` path which only runs for
plugins that opt into `@GrimEventHandler` scanning.

### Legacy 1.2.4 paths still work

`bus.post(event)`, `bus.subscribe(ctx, Class, GrimEventListener)`, and
`@GrimEventHandler` are all kept as `@Deprecated` and route through
each channel's legacy slot. A `bus.post(new FlagEvent(user, check, verbose))`
from an unmigrated plugin reaches both legacy-registered listeners AND
typed handlers (via each channel's `dispatchTypedFromLegacy` unpack
method).

### Tests + benchmark

25 unit tests pass — priority ordering, typed/legacy mixed dispatch,
cancellation threading, plugin-disable sweep, per-thread legacy pool
isolation.

JMH numbers from the `:benchmarks` subproject (kept local, not included
in this PR) on the reviewer's box:

```
Benchmark                                    (mode)  (subscribers)  Mode  Cnt    Score  Units
EventDispatchBenchmark.fire                   typed              0  avgt    3    2.17  ns/op
EventDispatchBenchmark.fire                   typed              1  avgt    3    7.19  ns/op
EventDispatchBenchmark.fire                   typed              3  avgt    3   13.51  ns/op
EventDispatchBenchmark.fire                  legacy              1  avgt    3   15.05  ns/op
EventDispatchBenchmark.fire                  legacy              3  avgt    3   21.99  ns/op
EventDispatchBenchmark.fire                   mixed              3  avgt    3   18.39  ns/op
```

`gc.count ≈ 0` and `gc.alloc.rate.norm ≈ 10⁻⁵ B/op` across every
configuration — zero-alloc fire() confirmed.

### Related

Grim-side migration PR that consumes this: GrimAnticheat/Grim#<pending>
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.

1 participant