FrogportMixinHelper calls Level.getBlockEntity from a Flywheel worker thread, corrupting LevelChunk.blockEntities
Summary
FrogportMixinHelper.getExactTargetLocation is invoked from
FrogportVisual.beginFrame, which Flywheel runs on its parallel task executor.
The method reaches Level.getBlockEntity, which mutates
LevelChunk.blockEntities — a Object2ObjectOpenHashMap with no
synchronisation — from a non-render thread.
NeoForge detects the illegal access and throws, so the immediate symptom is a
logged UnsupportedOperationException. The write is not cleanly rolled back,
however: the map is left in an inconsistent state, and the client crashes when
the render thread next iterates it. The two events can be minutes apart.
Disabling Flywheel's backend eliminates the problem entirely.
Environment
|
-- | --
Minecraft | 1.21.1
NeoForge | 21.1.235
Sable | 2.1.0 (also reproduced on 2.0.3)
Create | 6.0.10
Flywheel | 1.0.6, backend indirect
Sodium | 0.8.12+mc1.21.1
Java | 21.0.7 (Microsoft)
Environment | multiplayer client, dedicated server
Also installed: Create Aeronautics 1.3.0, Sable Companion 1.6.0,
Sable Sublevels on Xaero's Maps 1.0.34, Iris 1.8.14-beta.1, Veil 4.1.4.
The off-thread write
Logged on Flywheel Task Executor #5:
java.lang.UnsupportedOperationException: Cannot request ModelData refresh outside the owning thread: Thread[#1,Render thread,10,main]
at net.neoforged.neoforge.client.model.data.ModelDataManager.requestRefresh(ModelDataManager.java:51)
at net.neoforged.neoforge.common.extensions.IBlockEntityExtension.requestModelDataUpdate(IBlockEntityExtension.java:82)
at com.simibubi.create.content.kinetics.base.KineticBlockEntity.requestModelDataUpdate(KineticBlockEntity.java:596)
at net.minecraft.world.level.block.entity.BlockEntity.setRemoved(BlockEntity.java:228)
at com.simibubi.create.foundation.blockEntity.SmartBlockEntity.setRemoved(SmartBlockEntity.java:135)
at net.minecraft.world.level.chunk.LevelChunk.setBlockEntity(LevelChunk.java:408)
at net.minecraft.world.level.chunk.LevelChunk.addAndRegisterBlockEntity(LevelChunk.java:358)
at net.minecraft.world.level.chunk.LevelChunk.getBlockEntity(LevelChunk.java:349)
at net.minecraft.world.level.Level.getBlockEntity(Level.java:777)
at com.simibubi.create.content.logistics.packagePort.PackagePortTarget.be(PackagePortTarget.java:66)
at com.simibubi.create.content.logistics.packagePort.PackagePortTarget$ChainConveyorFrogportTarget.getExactTargetLocation(PackagePortTarget.java:177)
at com.simibubi.create.content.logistics.packagePort.frogport.FrogportBlockEntity.mixinextras$bridge$getExactTargetLocation$26(FrogportBlockEntity.java)
at dev.ryanhcode.sable.neoforge.mixinhelper.compatibility.create.frogports.FrogportMixinHelper.getExactTargetLocation(FrogportMixinHelper.java:25)
at com.simibubi.create.content.logistics.packagePort.frogport.FrogportBlockEntity.wrapOperation$flc000$sable$getExactTargetLocation(FrogportBlockEntity.java:527)
at com.simibubi.create.content.logistics.packagePort.frogport.FrogportBlockEntity.getYaw(FrogportBlockEntity.java:372)
at com.simibubi.create.content.logistics.packagePort.frogport.FrogportVisual.animate(FrogportVisual.java:74)
at com.simibubi.create.content.logistics.packagePort.frogport.FrogportVisual.beginFrame(FrogportVisual.java:68)
at dev.engine_room.flywheel.lib.task.Distribute.lambda$tasks$0(Distribute.java:48)
at dev.engine_room.flywheel.impl.task.ParallelTaskExecutor$WorkerThread.processTask(ParallelTaskExecutor.java:215)
at dev.engine_room.flywheel.impl.task.ParallelTaskExecutor$WorkerThread.run(ParallelTaskExecutor.java:205)
The target is a ChainConveyorFrogportTarget. Note that LevelChunk.java:408
is the branch that replaces an already-present block entity, so this is not
limited to frogports whose target is missing or unloaded.
Resulting crashes
The corrupted map surfaces later, on the render thread. Timings from one
session:
23:28:17 [Flywheel Task Executor #5] UnsupportedOperationException (above)
23:31:04 [Render thread] FATAL Failed to iterate block entities!
The gap varies. Across sessions it has been 6 seconds, 2 seconds, 2m47s, 3m48s
and 7 seconds — the crash happens whenever the render thread next touches the
affected map, not when the illegal write occurs.
In one session three worker threads hit the same chunk within the same
millisecond, which shows the concurrency directly:
01:14:05.937 [Flywheel Task Executor #5] ConcurrentModificationException
01:14:05.937 [Flywheel Task Executor #2] UnsupportedOperationException
01:14:05.937 [Flywheel Task Executor #1] UnsupportedOperationException
01:14:12.554 [Render thread] FATAL Failed to iterate block entities!
java.lang.RuntimeException: Failed to iterate block entities! This is *very likely* the fault of another misbehaving mod, not Sodium.
at net.caffeinemc.mods.sodium.client.world.cloned.ClonedChunkSection.tryCopyBlockEntities(ClonedChunkSection.java:165)
...
Caused by: java.lang.NullPointerException: Cannot invoke "it.unimi.dsi.fastutil.objects.ObjectArrayList.get(int)" because "this.wrapped" is null
at it.unimi.dsi.fastutil.objects.Object2ObjectOpenHashMap$MapIterator.nextEntry(Object2ObjectOpenHashMap.java:637)
The same corruption has surfaced in two other forms across sessions:
A ConcurrentModificationException, thrown directly on the worker thread when
the map in updateBlockEntityTicker is a plain HashMap:
java.util.ConcurrentModificationException
at java.util.HashMap.compute(HashMap.java:1325)
at net.minecraft.world.level.chunk.LevelChunk.updateBlockEntityTicker(LevelChunk.java:652)
at net.minecraft.world.level.chunk.LevelChunk.addAndRegisterBlockEntity(LevelChunk.java:364)
at net.minecraft.world.level.chunk.LevelChunk.getBlockEntity(LevelChunk.java:349)
... (same frogport chain as above)
And an ArrayIndexOutOfBoundsException during a later rehash of the
already-damaged map:
java.lang.ArrayIndexOutOfBoundsException: Index -1 out of bounds for length 33
at it.unimi.dsi.fastutil.objects.Object2ObjectOpenHashMap.rehash(Object2ObjectOpenHashMap.java:1274)
at it.unimi.dsi.fastutil.objects.Object2ObjectOpenHashMap.put(Object2ObjectOpenHashMap.java:259)
at net.minecraft.world.level.chunk.LevelChunk.setBlockEntity(LevelChunk.java:406)
at net.minecraft.world.level.chunk.LevelChunk.addAndRegisterBlockEntity(LevelChunk.java:358)
at net.minecraft.world.level.chunk.LevelChunk.getBlockEntity(LevelChunk.java:349)
at net.minecraft.world.level.Level.getBlockEntity(Level.java:777)
at net.neoforged.neoforge.client.model.data.ModelDataManager.refreshAt(ModelDataManager.java:127)
at net.caffeinemc.mods.sodium.neoforge.model.NeoForgeModelAccess.getModelDataContainer(NeoForgeModelAccess.java:37)
at net.caffeinemc.mods.sodium.client.world.cloned.ClonedChunkSection.<init>(ClonedChunkSection.java:54)
Reproduction
- Have one or more frogports targeting chain conveyors within render distance.
- Join the server with Flywheel's backend set to
indirect (or instancing).
- Play in that area. The off-thread error appears in the log, and a crash
follows anywhere from seconds to several minutes later.
Time to first error has ranged from 13 seconds to 19 minutes across sessions.
The target does not need to be broken or unloaded: LevelChunk.java:408 is the
branch that replaces an already-present block entity.
Breaking and re-placing every frogport in the area does not fix this. One
46-minute session on indirect afterwards was clean, but placing more
frogports back and playing for 19 minutes reproduced the crash. The number of
frogports in view appears to affect frequency rather than whether the bug
occurs: with more of them, Flywheel distributes more beginFrame work across
its workers, and the chance of two landing in the same chunk simultaneously
goes up.
Backend toggle
Toggling Flywheel's backend in a single session isolates the cause:
23:03:33 /flywheel backend off → 13 minutes, no errors
23:16:45 /flywheel backend indirect → error after 19 seconds, crash 2 seconds later
A second session behaved the same way: clean while off, first off-thread
error 9 minutes after switching back to indirect, crash 3 minutes after that.
instancing behaves identically to indirect — 13 seconds after switching to
it, the same off-thread error appeared. Only off avoids the problem, which is
consistent with the visual pipeline, rather than the geometry upload path,
being where the parallel work happens.
Things ruled out
- Not fixed in 2.1.0. Reproduced on the latest release with an identical
stack trace;
FrogportMixinHelper.java:25 is unchanged between 2.0.3 and
2.1.0.
- Not another backend.
instancing and indirect behave the same; see
above.
- Not a specific broken frogport. Breaking and re-placing every frogport in
the area did not help; the crash returned once more of them were back.
- Not chunk loading alone. Chunk-loading the frogport and its target
together reduced the frequency of the off-thread error but did not eliminate
it, and a crash still followed.
- Not location-specific. After moving a nearby Sable sub-level, the crash
reappeared at a different position with an identical stack trace.
- Not Connectivity. Removing it made no difference.
- Sodium is the reporter, not the cause. It reads a map that was already
corrupted; with the Flywheel backend off, Sodium never encounters a problem.
Full latest.log and crash reports attached.
FrogportMixinHelper calls Level.getBlockEntity from a Flywheel worker thread, corrupting LevelChunk.blockEntities
Summary
FrogportMixinHelper.getExactTargetLocationis invoked fromFrogportVisual.beginFrame, which Flywheel runs on its parallel task executor. The method reachesLevel.getBlockEntity, which mutatesLevelChunk.blockEntities— aObject2ObjectOpenHashMapwith no synchronisation — from a non-render thread.NeoForge detects the illegal access and throws, so the immediate symptom is a logged
UnsupportedOperationException. The write is not cleanly rolled back, however: the map is left in an inconsistent state, and the client crashes when the render thread next iterates it. The two events can be minutes apart.Disabling Flywheel's backend eliminates the problem entirely.
Environment
Also installed: Create Aeronautics 1.3.0, Sable Companion 1.6.0, Sable Sublevels on Xaero's Maps 1.0.34, Iris 1.8.14-beta.1, Veil 4.1.4.
The off-thread write
Logged on
Flywheel Task Executor #5:The target is a
ChainConveyorFrogportTarget. Note thatLevelChunk.java:408is the branch that replaces an already-present block entity, so this is not limited to frogports whose target is missing or unloaded.Resulting crashes
The corrupted map surfaces later, on the render thread. Timings from one session:
The gap varies. Across sessions it has been 6 seconds, 2 seconds, 2m47s, 3m48s and 7 seconds — the crash happens whenever the render thread next touches the affected map, not when the illegal write occurs.
In one session three worker threads hit the same chunk within the same millisecond, which shows the concurrency directly:
The same corruption has surfaced in two other forms across sessions:
A
ConcurrentModificationException, thrown directly on the worker thread when the map inupdateBlockEntityTickeris a plainHashMap:And an
ArrayIndexOutOfBoundsExceptionduring a later rehash of the already-damaged map:Reproduction
indirect(orinstancing).Time to first error has ranged from 13 seconds to 19 minutes across sessions. The target does not need to be broken or unloaded:
LevelChunk.java:408is the branch that replaces an already-present block entity.Breaking and re-placing every frogport in the area does not fix this. One 46-minute session on
indirectafterwards was clean, but placing more frogports back and playing for 19 minutes reproduced the crash. The number of frogports in view appears to affect frequency rather than whether the bug occurs: with more of them, Flywheel distributes morebeginFramework across its workers, and the chance of two landing in the same chunk simultaneously goes up.Backend toggle
Toggling Flywheel's backend in a single session isolates the cause:
A second session behaved the same way: clean while
off, first off-thread error 9 minutes after switching back toindirect, crash 3 minutes after that.instancingbehaves identically toindirect— 13 seconds after switching to it, the same off-thread error appeared. Onlyoffavoids the problem, which is consistent with the visual pipeline, rather than the geometry upload path, being where the parallel work happens.Things ruled out
FrogportMixinHelper.java:25is unchanged between 2.0.3 and 2.1.0.instancingandindirectbehave the same; see above.Full
latest.logand crash reports attached.