Class ChestSessionManager

java.lang.Object
com.enhancedechest.service.ChestSessionManager

public final class ChestSessionManager extends Object
Owns the shared-live-inventory registry and the dupe-safety contract for every open chest: this is the single closed class that mutates the sessions map, and it does so only on the onGlobal(java.lang.Runnable) bookkeeping thread. Every chest open funnels through open(org.bukkit.entity.Player, java.util.UUID, int, org.bukkit.Location) (the sole funnel point); concurrent admin operations chain behind forceCloseAll(java.util.UUID, int) + runExclusive(java.util.UUID, int, java.util.function.Supplier<T>).

Shared live inventory (concurrent-edit) model

Every open chest is backed by a single shared Inventory held in sessions, keyed by (owner, index). Owner and admin openInventory() the same object, so Bukkit serialises all item moves on one ItemStack[] — making item-level duping between concurrent viewers structurally impossible on a single-threaded platform.

Folia caveat: two viewers may live on different region threads, where a shared inventory is unsafe. On Folia we therefore allow only one live viewer per chest (a second opener is denied); on Paper concurrent editing is fully supported.

All session bookkeeping (the sessions map, viewer sets, attach/detach/persist decisions) runs on a single thread via onGlobal(java.lang.Runnable): the main thread on Paper, the global region thread on Folia. This removes registry-level races on both. The DB read and the byte→ItemStack decode both happen on the async executor (the stored bytes are immutable and the decoded stacks are handed to the global thread through the future's happens-before edge, untouched by any other thread) — only the cheap inventory build runs on the global thread. Encoding on save stays synchronous on the global thread and only ever happens once all viewers have closed (no concurrent edit during encode) — that half of the contract is load-bearing; do not move it off-thread.

Dupe-safety contract (preserved, now per shared session):

  • the first open of a chest waits for any in-flight async save of that same chest, then loads fresh from the DB; subsequent opens attach to the live session and never re-read the DB while it is open (the live inventory is authoritative).
  • the chest is persisted when its last viewer closes (or a force-close fires), encoding the shared contents synchronously on the global thread then flushing to the DB on a daemon thread, keyed by (owner, index).
  • flushPendingSaves() in onDisable() blocks until all writes finish before the pool closes.
  • Constructor Details

  • Method Details

    • open

      public void open(org.bukkit.entity.Player player, UUID owner, int index, @Nullable @Nullable org.bukkit.Location sourceBlock)
      The single funnel through which every chest open passes. On the player's entity thread: a request for the chest they are already viewing is dropped (it is a stale duplicate — closing and reopening would churn a save/load cycle and replay the lid sound); a different chest GUI is closed first (flushing its session). Then hands off to decideOpen(org.bukkit.entity.Player, java.util.UUID, int, org.bukkit.Location) on the global bookkeeping thread to attach to — or create — the live session for (owner, index).
    • detach

      public void detach(org.bukkit.entity.Player player, EnderChestHolder holder)
      Detaches a viewer when they close the shared GUI (called from the GUI close and quit listeners on the player's entity thread). Removes them from the session on the global thread and, if they were the last viewer, persists the shared contents. A no-op if the session was already torn down by a force-close (which persists itself) — so the same close never double-saves.
    • hasActivity

      public boolean hasActivity(UUID owner)
      True while any chest of owner has a live session or an in-flight save/exclusive op. Read-only scan of the two concurrent maps, safe from any thread. The cross-server handover handler consults this before flushing + releasing an owner another server asked for: while a close-save is still in flight the handover simply waits for the requester's next ask, so the flush can never miss the final contents of a chest that was open at quit time.
    • runExclusive

      public <T> CompletableFuture<T> runExclusive(UUID owner, int index, Supplier<T> dbWork)
      Serializes arbitrary DB work for one (owner, index) behind any in-flight save/op for that key, registering it in pendingSaves so a concurrent open waits for it. The work runs on the async executor; the returned future completes with its result (or its failure).
    • runExclusiveAcross

      public <T> CompletableFuture<T> runExclusiveAcross(List<ChestSessionManager.ChestRef> refs, Supplier<T> dbWork)
      Like runExclusive(java.util.UUID, int, java.util.function.Supplier<T>) but spanning several chests at once: chains dbWork behind the in-flight save/op of every ref, and registers a marker for each so a concurrent open of any of them waits for the work to finish. Used by the multi-chest, two-player /ee transfer so its whole DB transaction is one dupe-safe critical section.

      Callers must forceCloseAll(java.util.UUID, int) every ref first (to flush live edits); this only serialises the DB work behind the resulting saves. The work runs on the async executor; the returned future completes with its result (or its failure).

    • forceCloseAll

      public CompletableFuture<Void> forceCloseAll(UUID owner, int index)
      Force-closes the GUI of every viewer of (owner, index), then persists the shared contents and tears down the session — returning a future that completes once the save has been registered in pendingSaves. The caller can then chain an exclusive op that serialises behind that save, keeping admin resize/delete dupe-safe even with multiple concurrent viewers.

      The persist runs only after all viewer screens have actually closed (their close handlers see closing and skip their own save), so the shared inventory is read with no viewer still editing it — safe to encode on the global thread even on Folia.

    • isActivityRecording

      public boolean isActivityRecording()
      Marks a chest as edited during its current session, from the click/drag listener on the viewer's own thread. Only the activity log reads this, and only to decide whether capturing the closing contents is worth the work: a chest nobody touched cannot have changed, so its visit is dropped without building a snapshot at all. A no-op when the log is off or there is no live session.
    • markTouched

      public void markTouched(UUID owner, int index)
    • shutdown

      public void shutdown()
      Flushes all live sessions and waits for their writes before the plugin disables. Does not close the async executor — that pool is owned by DbExecutor and shut down separately, after this returns, so the flush above can still dispatch onto it.