MCRcortex / MCRcortex/voxy

Save and Quit can hang indefinitely: SemaphoreBlockImpersonator keeps Sodium chunk-builder workers from observing ChunkJobQueue shutdown

Open
#683 1 comment 0 reactions 0 assignees View on GitHub

Nobody has claimed this yet.

Dominant language
Java
Stars
1.2k
Forks
1.1k
PR merge metrics
No merged PRs in 30d

Description

Summary

When Voxy's Sodium compatibility shim is active (default: dont_use_sodium_builder_threads=false), a Save and Quit can hang indefinitely on the "Saving world" screen. Voxy's MixinChunkJobQueue replaces the Semaphore inside Sodium's ChunkJobQueue with SemaphoreBlockImpersonator over a MultiThreadPrioritySemaphore.Block, so Sodium's chunk-builder workers run Voxy service jobs synchronously while they wait for Sodium work. A worker that is inside that path when Sodium shuts the queue down never returns to its loop head, never observes isRunning == false, and never exits — and Sodium's ChunkBuilder.shutdownThreads() is an unbounded Thread.join(), so the render thread parks forever.

Observed in a real session: the render thread stayed parked in Thread.join() for 3 h 51 m (until the wedged worker happened to exit), and a subsequent quit died with RuntimeException: Shutting down async culling task executor timed out from RenderSectionManager.destroy, leaving the client stranded until it was killed externally. The world save itself completed losslessly in both cases ("All dimensions are saved").

Environment: Minecraft 26.2, Java 25, Sodium mc26.2-0.9.1, Voxy 0.2.18-beta, Iris 1.11.2+26.2 (part of the MC³ modpack).

Mechanism

Sodium's worker loop only notices shutdown when acquire() returns:

// ChunkBuilder$WorkerRunnable.run (Sodium 0.9.1; identical in 0.9.2)
while (queue.isRunning()) {
    job = queue.waitForNextJob();   // semaphore.acquire()
    ...
}

ChunkJobQueue.shutdown() releases availableProcessors() permits so workers parked in acquire() wake up and exit. Under the impersonator, however, acquire() delegates to MultiThreadPrioritySemaphore$Block.acquire(true) (decompiled semantics):

while (true) {
    blockSemaphore.acquireUninterruptibly();
    if (localSemaphore.tryAcquire()) return;   // real Sodium job permit
    if (!man.tryRun(this)) /* loop */;         // otherwise run Voxy service jobs inline
    // tryRun == true -> return
}

and MultiThreadPrioritySemaphore.tryRun(block):

if (!pooledSemaphore.tryAcquire()) return false;
while (true) {
    int r = executor.getAsInt();          // ServiceManager.tryRunAJob -> runAJob0 -> Service.runJob
    if (r == 0 || r == 1) return false;   // ran a job / no jobs -> back to blocking acquire
    if (r >= 2) {
        if (block.localSemaphore.tryAcquire(10, MS)) { pooledRelease(1); return true; }
        // else loop and keep running Voxy jobs
    }
    // r == 3 (all services limiter-blocked): hot spin — no sleep, no local-permit re-check
}

Two wedge paths follow:

  1. Deep save backlog: Service.runJob() executes synchronously on the Sodium worker (e.g. SectionSavingService.processJob → RocksDB putDirect). While the backlog keeps producing work, the worker stays inside tryRun and only re-checks for a Sodium permit on the r >= 2 sub-path. If Voxy work never runs dry, acquire() never returns after ChunkJobQueue.shutdown() has already released its permits, and the worker never re-checks isRunning().
  2. r == 3 spin: when all services are limiter-blocked the loop spins with no sleep and no local-permit re-check — a durable wedge candidate even without backlog.

blockSemaphore.acquireUninterruptibly() additionally discards the interruptibility Sodium's queue design expects (waitForNextJob declares InterruptedException; cf. CaffeineMC/sodium#2012, which replaced interrupt-based termination with permit release — the impersonator breaks the wake-up side of that design).

Evidence

Thread dump from the affected session, taken during the save phase — both Sodium chunk-builder workers inside the impersonated acquire running Voxy save jobs (Block.acquire line 81 = inside the tryRun branch):

"Chunk Render Task Executor #0" RUNNABLE
    at org.rocksdb.RocksDB.putDirect(Native Method)
    at me.cortex.voxy.common.config.storage.rocksdb.RocksDBStorageBackend.setSectionData
    at me.cortex.voxy.common.world.service.SectionSavingService.processJob
    at me.cortex.voxy.common.thread.Service.runJob
    at me.cortex.voxy.common.thread.ServiceManager.runAJob0
    at me.cortex.voxy.common.thread.ServiceManager.tryRunAJob
    at me.cortex.voxy.common.thread.MultiThreadPrioritySemaphore.tryRun
    at me.cortex.voxy.common.thread.MultiThreadPrioritySemaphore$Block.acquire
    at me.cortex.voxy.client.compat.SemaphoreBlockImpersonator.acquire
    at net.caffeinemc.mods.sodium.client.render.chunk.compile.executor.ChunkJobQueue.waitForNextJob(ChunkJobQueue.java:44)
    at net.caffeinemc.mods.sodium.client.render.chunk.compile.executor.ChunkBuilder$WorkerRunnable.run(ChunkBuilder.java:177)

Healthy-run contrast (same pack, same world, quit attempted with a fully drained Voxy backlog): both workers parked at Block.acquire line 77 (the blocking acquireUninterruptibly branch) at quit time, observed the permit release, exited immediately, and the whole teardown completed in ~13 s. So the wedge requires the worker to be inside tryRun (or the r == 3 spin) when shutdown fires.

The triggering session had a very deep Voxy LOD save backlog built up over hours of exploration, with the drain stretched by severe cgroup memory throttling — i.e. quit happened while SectionSavingService still had hours of work left.

Full thread-dump series and decompiled bytecode of all involved classes are available as lab artefacts if useful.

Affected versions

  • Voxy 0.2.18-beta and 0.2.19-beta (MultiThreadPrioritySemaphore last changed 2026-07-09; the impersonation is unchanged on dev).
  • Any Sodium with the current ChunkJobQueue/ChunkBuilder design (verified mc26.2-0.9.1 and 0.9.2).

Suggested fix

  • Bound the tryRun loop inside Block.acquire: periodically re-check the caller side (e.g. escape if no Sodium permit arrives within a bounded time even while Voxy work continues, so the worker returns to waitForNextJob and can observe isRunning == false).
  • On the r == 3 (all services limiter-blocked) path, add a sleep/backoff and a local-permit re-check instead of hot-spinning.
  • Optionally make the block wait interruptible again so a future interrupt-based shutdown can reach workers.

Workaround

Setting dont_use_sodium_builder_threads: true in voxy-config.json disables the impersonation entirely (Voxy keeps its own service threads). With that set, Sodium's permit-release shutdown works as designed; the cost is losing Sodium's idle chunk-builder workers as extra Voxy service capacity.

Contributor guide

No contributing guide indexed for this repository

First steps

  1. Read the whole issue, then the project's contributing guide.
  2. Comment on the issue to say you are picking it up — it saves two people doing the same work.
  3. Fork the repository and make your change on a branch.
  4. Open a pull request that references the issue number.

Research direction

Start by reading SemaphoreBlockImpersonator.acquire, MultiThreadPrioritySemaphore$Block.acquire and tryRun, and MixinChunkJobQueue, then reproduce Save and Quit with the Sodium compatibility shim enabled and a deep Voxy save backlog. Trace the shutdown path through ChunkJobQueue.shutdown and ChunkBuilder.shutdownThreads. Done means chunk-builder workers observe shutdown and teardown completes without an indefinite join.

Written by the indexing model from the issue text.

Assessment

Tech stack
java
Domain
backend, devtools, performance
Issue type
Bug
Difficulty
4/5
Estimated time
3-5 days
Activity status
Active
Clarity
Mostly clear
Newbie friendliness
42/100

Get new issues in your inbox

A short digest of beginner-friendly GitHub issues.