Bug Closed · Resolved

Model teardown races with parallel ticks

#10144 opened by chikage_ 2 days ago

chikage_reported this bug
  • Version: ModelEngine R4.2.0, Leaf 1.21.4-525
  • Period covered: Oct 18 to Oct 31, 2026
  • Occurrences: 6

Checked against ModelEngine-R4.2.0.jar. The line numbers in the stack traces match the bytecode of this jar.

Failure Occurrences Location
"models" is null 3 ModelUpdaters.forRenderers:167
"bones" is null 1 DisplayRendererImpl.forBehavior:444
"source" is null 1 ActiveModelImpl$ResolvedBoneOrder.resolve:584
ConcurrentModificationException during EntitiesUnloadEvent 1 DisplayParser.remove:521

Logs

[08:04:41] [ForkJoinPool-7-worker-9/WARN]: java.lang.NullPointerException: Cannot invoke "java.util.Map.isEmpty()" because "models" is null
[08:04:41] [ForkJoinPool-7-worker-9/WARN]: 	at ModelEngine.jar//com.ticxo.modelengine.api.model.ModelUpdaters.forRenderers(ModelUpdaters.java:167)

[03:22:58] [ForkJoinPool-7-worker-8/WARN]: java.lang.NullPointerException: Cannot invoke "java.util.Map.get(Object)" because "bones" is null
[03:22:58] [ForkJoinPool-7-worker-8/WARN]: 	at ModelEngine.jar//com.ticxo.modelengine.core.model.render.DisplayRendererImpl.forBehavior(DisplayRendererImpl.java:444)

[08:35:35] [ForkJoinPool-6-worker-6/WARN]: java.lang.NullPointerException: Cannot invoke "java.util.Map.get(Object)" because "source" is null
[08:35:35] [ForkJoinPool-6-worker-6/WARN]: 	at ModelEngine.jar//com.ticxo.modelengine.core.model.ActiveModelImpl$ResolvedBoneOrder.resolve(ActiveModelImpl.java:584)
[08:35:35] [ForkJoinPool-6-worker-6/WARN]: 	at ModelEngine.jar//com.ticxo.modelengine.core.model.ActiveModelImpl.tick(ActiveModelImpl.java:235)
[03:19:13] [Server thread/ERROR]: Could not pass event EntitiesUnloadEvent to ModelEngine vR4.2.0
java.util.ConcurrentModificationException: null
	at java.base/java.util.HashMap.forEach(HashMap.java:1433) ~[?:?]
	at ModelEngine.jar/com.ticxo.modelengine.v1_21_R3.parser.model.DisplayParser.remove(DisplayParser.java:521) ~[ModelEngine.jar:?]
	at ModelEngine.jar/com.ticxo.modelengine.v1_21_R3.parser.model.DisplayParser.destroy(DisplayParser.java:204) ~[ModelEngine.jar:?]

Problem

Teardown and ticks can overlap. ModelUpdaters.tick submits work to the pool and returns while workers keep running. forceRemoveModeledEntity destroys the model at once on Paper-based servers, including from EntitiesUnloadEvent on the main thread. Only the Folia path defers the destroy.

getModels() can return null. ModeledEntityImpl.getModels() reads cachedModels for its null check, then reads it again to return it. destroy() sets that field to null, so it can change between the two reads. This matches "models" is null.

getBones() has the same race. CachedImmutableMap.get() also reads its volatile cache twice, and ActiveModelImpl.destroy() calls invalidate(), which sets it to null. Both forBehavior and ActiveModelImpl.tick get their map from getBones(), which matches "bones" is null and "source" is null.

The rendered bones are in a plain HashMap. DisplayParser.remove iterates renderer.getRendered() during teardown, while DisplayRendererImpl writes to that map from the render step on a worker. This matches the ConcurrentModificationException.

Solution

  1. In getModels() and CachedImmutableMap.get(), store the field in a local variable, then check and return that same value.
  2. Defer destroy() until the running tick has finished, on every platform.
  3. Have the tick stop safely when its model is being destroyed.
  4. Keep rendered and the other render maps on one owner, or iterate a safe snapshot during teardown.
Sign in to join the discussion.