Skip to content

FrogportMixinHelper calls Level.getBlockEntity from a Flywheel worker thread, corrupting LevelChunk.blockEntities #1512

Description

@Rwolder

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

  1. Have one or more frogports targeting chain conveyors within render distance.
  2. Join the server with Flywheel's backend set to indirect (or instancing).
  3. 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.

Metadata

Metadata

Assignees

No one assigned

    Labels

    type: crashSomething crashes my game

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions