Threading Rules
Paper runs the world on one thread. Blocking that thread stalls every player, and touching the world from any other thread corrupts it. StoneLib does not hide either rule. This page lists, module by module, where each call is allowed to run.
The two rules
- Never block the main thread on I/O. Database calls, and file saves of any size, belong off the main thread.
- Never touch the world off the main thread. Entities, blocks, inventories, and most of the Bukkit API are main-thread only. Sending packets to a player, such as particles and sounds, is the usual exception.
The shape that satisfies both is "load off-thread, apply on the main thread", which is exactly what SchedulerService.supplyAsync does:
scheduler.supplyAsync(
() -> repository.reload(uuid), // runs async: may block
profile -> applyToPlayer(profile)); // runs on the main thread: may touch the world
Calls that block
These wait on a database or the filesystem. Call them off the main thread, except during startup and shutdown where a short block is acceptable.
| Call | Waits on |
|---|---|
new ConnectionPool(...) | MySQL. HikariCP opens a first connection while constructing, and throws if it cannot. |
Every ConnectionPool method: connection, update, query, queryOne, transaction | MySQL |
SchemaMigrator.migrate, currentVersion, hasRun | MySQL |
MySqlRepository.createTable, load, reload, save, saveNow | MySQL |
SqliteRepository.load, save | The SQLite file |
YamlRepository.load, save | The YAML file |
ConfigManager, MultiConfigManager, and MessageService constructors and reload | Config files. Small and fast, normally fine on the main thread. |
MySqlRepository.get, put, remove, all, and invalidate only touch the in-memory cache and never block.
Calls that run off the main thread for you
| Call | Runs on | Allowed inside |
|---|---|---|
SchedulerService.runAsync, runAsyncLater, runAsyncTimer | An async scheduler thread | Blocking I/O. No world access. |
The supplier passed to SchedulerService.supplyAsync | An async scheduler thread | Blocking I/O. No world access. |
Renderable.render | The render loop's async task | Packets to the given viewers, and reading the RenderContext. No world access. |
PermissionService.grant, grantTemporary, revoke futures | LuckPerms' own executor | Completing the future. Hop back to the main thread before touching the world. |
Calls that must be on the main thread
| Call | Why |
|---|---|
HologramService.create, update, remove, has | Spawns, edits, and searches entities. |
MenuHolder.open | Creates and opens an inventory. |
FormDialog.Builder.show | Shows a dialog to a player. |
PermissionService.resolve | Calls Player.hasPermission. |
LootTable.roll | Builds ItemStacks. Safe to compute off-thread, but hand the items to the world on the main thread. |
MessageBus.register, unregister, publish | Registers channels and sends through a player's connection. |
RenderLoop.start | Schedules the snapshot task. |
Callbacks and which thread they arrive on
| Callback | Thread |
|---|---|
supplyAsync main-thread callback | Main thread |
MenuHolder.ClickHandler | Main thread (it is an inventory event) |
FormDialog submit and cancel handlers | Whichever thread Paper delivers dialog clicks on. StoneLib does not reschedule them, so if a handler touches the world, check Bukkit.isPrimaryThread() or hop with runSync. |
MessageBus.MessageHandler | The thread the plugin message arrives on. Treat it as unknown: do blocking work via runAsync and world work via runSync. |
Renderable.cleanUp | Always the main thread. The loop reschedules it there if it was triggered off-thread. |
Thread-safe structures
These are safe to call from any thread:
CooldownService, which is backed by concurrent maps.PermissionService.has,cached,invalidate, andinvalidateAll. Note thathasfalls back to a livehasPermissioncheck on a cache miss, which should stay on the main thread.MySqlRepository's cache operations.RenderLoop.add,remove,get, andsize.MiniMessages,UntrustedText,Durations,LocationCodec.serialize, andMessageencoding, which hold no state.
YamlRepository and SqliteRepository are not thread-safe. Their caches are plain LinkedHashMaps, so do not call put from one thread while save runs on another. Either keep all access on the main thread and save a snapshot, or guard the repository yourself.