Skip to content

fix: EventNodeImpl handle cache preventing event nodes from being garbage collected - #208

Open
DragonL0508 wants to merge 1 commit into
FairyProject:v0.7from
DragonL0508:fix/event-node-classvalue-leak
Open

DragonL0508 wants to merge 1 commit into
FairyProject:v0.7from
DragonL0508:fix/event-node-classvalue-leak

Conversation

@DragonL0508

Copy link
Copy Markdown

EventNodeImpl caches its handles in a ClassValue. A ClassValue stores each value inside the Class it is computed for (a WeakHashMap keyed by the ClassValue's identity), and here the value strongly references the key: Handle is an inner class holding its node, and the node holds the ClassValue. Such an entry is never cleared, and loaded event classes are GC roots, so once a handle has been computed for a node, that node and everything its listeners capture stay in memory forever. A parent asks every child for a handle whenever it builds a consumer, so this happens to practically every node.

The GUI module creates a node for every opened Gui (and one per slot), so every opened menu leaked together with its slots and items. On our production server this grew to about three million StaticGuiSlot instances, 8-10 GB of a 16 GB heap, with Full GC pauses of up to 5 seconds. Gui#close removing the node from its parent doesn't help, since the entries live in the event classes.

The cache is now a ConcurrentHashMap owned by the node, so it is collected together with it. invalidateEvent skips types that never had a handle requested (a ClassValue used to create one on demand). We have been running this change as a patched class in production.

Tests: EventNodeGarbageCollectionTest attaches a child with a listener, dispatches through the parent, removes the child and checks that it can be collected (retrying System.gc() up to 50 times). It fails before this change. Since it relies on the GC, feel free to @Disabled it if you'd rather not have it in CI, like checkIfPrototypeCanBeGarbageCollected.

🤖 Generated with Claude Code

…bage collected

EventNodeImpl caches its handles in a ClassValue. ClassValue stores each
value inside the Class object it is computed for (a WeakHashMap keyed by
the ClassValue's identity), and the value strongly references the key
here: Handle is an inner class holding its node, and the node holds the
ClassValue. Such an entry is never cleared, and loaded event classes are
GC roots, so once a handle has been computed for a node, that node and
everything its listeners capture stay in memory forever. A parent asks
every child for a handle whenever it builds a consumer, so this happens
to practically every node.

Fairy's GUI creates a node for every opened Gui (and one per slot), so
every opened menu leaked together with its slots and items. On a
production server this accumulated to around three million
StaticGuiSlot instances, 8-10 GB of a 16 GB heap, and Full GC pauses of
up to 5 seconds. Removing the node from its parent (Gui#close) does not
help, since the entries live in the event classes.

Use a ConcurrentHashMap owned by the node instead, so the cache is
collected together with it. invalidateEvent now skips types that never
had a handle requested, which a ClassValue used to create on demand.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant