Folia support (experimental)
Status: experimental. The core paths (scheduler / tasks / events / commands / timers / PDC / text / permissions) are verified on real Folia. Tasks and events are aligned with Paper, but Folia's regionised multithreading makes a few behaviours impossible to align - those differences fail as-is, they are never special-cased. See Known differences.
Environment requirements and server support range
There are hard requirements on both the runtime and the server, and the measured support range is tiered (table below). Environments below the requirement are never degraded silently - plugins fail with an explicit error.
Supported range (measured with the multi-version matrix, 2026-09-26):
| Minecraft | Result |
|---|---|
| 1.21.1 / 1.21.4 | ✅ all green (Paper and Folia verified) |
| 1.18.2 / 1.19.4 / 1.20.4 | ⚠️ partially usable: the server and the runtime both load (the Java 17 target lets the older remapper read the classes); 1.20.4 measured 111 passed / 8 failed, all failures being fake-client actions not applied server-side, while 1.18.2/1.19.4 additionally hit runtime Registry.* constant compatibility issues that still need fixing |
| 1.21.8 | ⚠️ the server loads, but the fake client's actions are not accepted (suspected 1.21.6+ input/loading sequence change); reported red |
| 26.1 / 26.2 | n/a (not published upstream / client protocol data missing) |
Java 17+ is a hard requirement: the runtime, the template and the JNI bridge are all compiled for Java 17 (class file 61), so the plugin remapper of older Paper (1.18-1.20) can read them too. The server's own version requirement is unchanged (1.18 needs Java 17+); below Java 17 the plugin is refused with
UnsupportedClassVersionError.
Experimental notes
Folia is a Paper fork whose core difference is regionised multithreading: there is no global main thread; the world is split into regions that tick in parallel on separate threads. This breaks Yeow's synchronous bridge model, which was built around a single main thread, so Folia support is an independent runtime implementation rather than an adaptation of the Paper runtime.
⚠️ Never use the synchronous API (anywhere). A sync call blocks the plugin's only JS thread, while the events it itself triggers must be handled by that same thread - a self-deadlock until the 5s timeout. See the Synchronous-API warning below.
Coverage:
| Area | Coverage |
|---|---|
| World | block read/write (including BlockData states), time/weather/difficulty/gamerules (global-state writes are routed to the global thread automatically), spawn point, biome, light, entity query/spawn, drops/lightning/explosion/sound/particles/chunk snapshots |
| Player | attributes/position/teleport/messages (MiniMessage)/Title/ActionBar/sound/item/resource pack/permissions (including the permission check (onPermissionCheck) hook) - 40+ operations |
| Entity | attributes/custom name/health/glowing/invulnerable/silent/gravity/bounding box/passengers/vehicle/potion effects |
| Data | PDC (entity/block/world), Material registry queries and static checks |
| Events | Bukkit events aligned (player/entity/block/inventory/server) + the permission check (onPermissionCheck) hook |
| Commands | register/unregister/execute/tab completion (permission nodes with PermissionDefault) |
| Inventory/misc | unified Inventory (player inventory / container blocks / custom chest GUIs), BossBar, Scoreboard (creation is restricted, see below), Recipe, Advancement |
| Runtime | hot reload (dev WS), timers, fs/http/assets/service channels (in core) |
How Yeow is built on Folia
Layers
yeow-runtime/jvm/
├── core/ platform-agnostic engine (QuickJS, message bridge, plugin lifecycle, TaskScheduler contract) - shared
├── paper/ Paper implementation (PaperScheduler: the main thread consumes the three pools each tick)
└── folia/ Folia implementation (FoliaScheduler + FoliaTasks + event/command bridges, written separately)The engine is identical on both platforms; every platform difference is confined to the scheduler and the task executor.
Folia's constraints (why the architecture looks like this)
Folia's regionised multithreading is a huge achievement, but precisely because of its internal complexity the concurrency primitives it exposes to upper layers are scarce. Understanding these four constraints explains why Yeow's scheduler is "its own queues, its own budget, its own timeouts, with Folia as a transport layer":
- Region threads are not stable: the region-to-thread binding is not a contract - regions load/unload and the thread pool can be reshuffled. Never assume "region X = thread Y" holds. The residency marker is therefore re-resolved on every cycle start (
runCycleOn); thread references are never cached. - Threads cannot be identified: the API does not expose "the thread a task will run on". Identity can only be established by ownership queries (
isOwnedByCurrentRegion), never by thread name/id. - Scheduling queues cannot be introspected: each region scheduler's internal queue is a black box - it cannot be inspected, counted, or asked whether a task has been enqueued or will run. Yeow must own its task pools entirely (three priority levels).
- Post-dispatch control is limited: no enqueue interception, no completion observation (the region scheduler has no retired/completion callback), no priorities; after dispatch only handle-level control remains (
cancel()/getExecutionState()), and a task may silently never run (world unload, region stall).
How Yeow compensates (this is where the Folia implementation's complexity lives):
- Own queues + three priorities + budget + migration: Folia is only a transport layer;
- Task ids + an in-flight table: every dispatch registers an entry (the task, its target marker, the plugin, a shared completion switch, a dispatch guard), which makes already-dispatched work observable, arbitrable and reclaimable again;
- A 5s timeout fallback: every wait is bounded, so there is no deadlock by construction;
- In-flight reclamation: when an event fires, the plugin's dispatched-but-not-yet-started tasks are taken back and executed by the event thread - see event mode.
Available primitives
| Primitive | Purpose |
|---|---|
Bukkit.isOwnedByCurrentRegion(target) | ownership query (core) - the basis of ownedHere |
the target's scheduler (entity.getScheduler() / getRegionScheduler() / getGlobalRegionScheduler()) | dispatch channel (core) - the basis of the getScheduler closure |
Bukkit.isGlobalTickThread() | ownership extension - GLOBAL tasks run in place during a global transient |
ScheduledTask.cancel() | handle-level extension - used only for best-effort ghost-execution protection on timeout/unload; reclamation does not rely on it |
getGlobalRegionScheduler().runAtFixedRate | periodic extension - per-tick maintenance: budget restart / watchdog (1s liveness) / coarse in-flight timeout sweep |
Scheduling model: a non-blocking scheduler plus region residency
Folia has no main thread to spin on, so Yeow's synchronous bridge (a JS synchronous call waiting for a game-thread result) had to be redesigned:
- The scheduler is not a thread: it "resides" on some region's thread and runs as a dispatch cycle - the cycle itself is dispatched onto the residency region's thread and consumes the task pools in that thread's gaps;
- Non-blocking dispatch: the cycle takes a task and dispatches it immediately (in place when the target is the residency region, otherwise asynchronously to the target region) and never waits for a result - the target thread notifies on completion (completing the future, waking the scheduler). Tasks therefore run in parallel across regions (bounded by the in-flight limit);
- JS synchronous semantics are preserved: the plugin's JS thread still blocks on
future.get- the wait happens on the JS thread, not on the scheduler.
The three-function task contract
The scheduler knows nothing about task types; each type (shared per family) provides exactly three things (inside FoliaTasks): ownedHere(params) (ownership, decided directly from raw parameters), getScheduler(params) (a DispatchTarget{ marker, run } with a pure-string residency marker and a lazy dispatch closure), and execute(params) (the task body). Dispatch closures run through the dispatch guard first: a reclaimed task returns immediately instead of executing a second time.
Dispatch cycle
On the thread of residency region X:
queue non-empty -> keep processing:
├─ event/completion mode active -> exit (the event thread takes over, after in-flight reclamation)
├─ in-flight count >= limit (default 100) -> exit (woken by completion callbacks)
├─ budget exhausted (wall clock) -> exit (per-tick maintenance restarts at the next tick boundary)
├─ take a task -> ownedHere(params) (or a GLOBAL task on the global thread) -> run in place (microseconds)
│ otherwise -> path B: getScheduler().run dispatch (registers an in-flight entry)
└─ `migration-threshold` consecutive non-local tasks -> yield the residency marker
queue empty -> park for `folia.scheduler-idle-wait-us` (default 2000us), exit if still emptyIdle waiting parks (
Object.waitoncycleLock) rather than spinning; new tasks, completions and the end of event mode wake it vianotifyAll. It covers only the cycle's own iteration gaps (typically < 50µs), not the JS thread's OS wake-up latency. Spinning was rejected: its microsecond advantage exists only at low load, while it permanently occupies a core.
- Budget: within each 50ms window the scheduler may be active for at most
folia.tick-budget-ms(default 20ms) of wall-clock time; it measures elapsed time, not the sum of task durations (which would over-count when regions run in parallel). Single tasks may overrun the budget (it is checked between tasks). Restarts at tick boundaries can add a one-off pause of up to ~30ms under sustained load - inherent to the budget model. - In-flight limit:
folia.max-inflight(default 100) caps concurrent dispatches. - Timeout fallback: dispatched tasks are registered; a per-tick coarse sweep (every 1s) cancels and errors any task outstanding for 5s (already-running/reclaimed entries are no-ops) - without it a task that never runs would leak its in-flight slot forever. Reclaimed entries are excluded from timeout accounting (responsibility moved).
- Watchdog: if the cycle should be running but has been inactive for over 1s it is force-restarted.
- Priority: higher priority always wins (HIGH -> NORMAL -> LOW, FIFO within a pool); LOW starvation is avoided by submission-time demotion (a task type called more than
demote-thresholdtimes per second, default 200, drops to LOW and recovers later).
Migration
Residency follows the hot spot through yield + claim: after folia.migration-threshold (default 2) consecutive non-local tasks the residency marker becomes ownerless (the cycle does not give up its thread), and the next task that runs claims it; when the cycle restarts it anchors to the new marker (runCycleOn), dropping stale markers for unloaded worlds/entities.
Event/completion mode: temporary scheduler + in-flight reclamation
- The general scheduler pauses: the dispatch cycle exits while any event is active and resumes at count zero.
- The region thread that fires the event becomes a temporary scheduler: it spins consuming the pools, taking only tasks of the event's plugin (filtered by plugin name, no ownership resolution), and does not migrate.
- ★ In-flight reclamation: as soon as event mode is entered, the plugin's dispatched-but-not-yet-started tasks are taken back and executed by the event thread.
- Events are not mutually exclusive: several can run on different regions at once.
- Per-round cap (64) keeps the event thread responsive, and each task still goes through the executor (in place when the current thread owns the target, otherwise dispatched).
Why reclamation is needed (the closed loop)
Folia's region scheduler is uncontrollable: once dispatched, execution depends on the target region's tick. When an event fires on that very region - Folia merges regions, so with a single player the world is effectively one region - the three parties wait for each other:
the JS thread waits for its dispatched task <-> the event thread waits for JS to complete the event
<-> the dispatched copy waits for that region's tick (held by the event)Nothing can progress until the two 5s timeouts expire, which also trips Folia's watchdog. Reclamation takes control back.
Mechanism: task ids + an in-flight table + an atomic dispatch guard
Task ids: globally unique, monotonically increasing int64 ids assigned by core at submission (
TaskIds.next(), passed through theTaskSchedulersubmit signature; platforms must not allocate their own), carried through enqueue -> dispatch -> reclamation -> completion. Folia uses the id as the in-flight table key and reclamation ordering baseline, and it is correlated with logs / profile samples (TaskMetric.id, e.g.[sched] reclaim id=… task=… marker=…).In-flight table: each dispatch registers the task, its marker, the plugin, a shared completion switch (
done), an idempotent release switch and the dispatch guard.Dispatch guard: the dispatched Runnable is wrapped so that, immediately before executing the body, an atomic state decides who runs it:
PENDING --(Folia copy tryStart)--> RUNNING the copy runs normally PENDING --(event thread tryReclaim)--> RECLAIMED the copy returns at once and its result is droppedOnly the CAS winner may execute the body, so double execution is impossible by construction; this removes any dependency on
ScheduledTask.cancel()and any need to reason about thread ownership.Reclamation:
reclaimForPlugins(plugins)filters by plugin name only (a cheap string comparison, noownedHerecall), and for entries stillPENDINGthat wintryReclaim()it returns the in-flight slot, removes the entry, and runs the task through the normal execution path (processTask): in place when the current thread owns the target, otherwise dispatched again (tracked by a fresh entry, and re-checked while in-flight stays saturated).Accounting: release (table removal +
inflight--) is an idempotent atomic switch, safe against concurrent reclamation/completion; result delivery happens exactly once thanks to the shareddone.Ordering: reclamation walks entries in id order.
Capability boundary: reclamation can only save tasks that are already dispatched and legally executable by the current thread (the target belongs to this thread). It cannot save "synchronously waiting for a semantic that necessarily completes on another tick" (teleports being the canonical example) - that is a deadlock at the model level, see the "Synchronous-API warning" section below.
Residual trade-off: during an event, other plugins' queued tasks are not consumed (the cycle is paused); their synchronous calls wait for the event to end (bounded by the 5s event timeout). The one anti-pattern to avoid is an event listener that enables manual mode (
manualRelease) and waits for another plugin/worker service that touches the game - bounded by the same timeout.
The plugin's JS thread is the pivot
Each plugin has exactly one JS thread (messages are serialised), and an event occupies it. That is why taking tasks by plugin during an event is correct: the handler's synchronous calls (e.player.location, event.complete, ...) can always be consumed in place by the event thread - no staging, no cross-plugin coordination - while different plugins' events run concurrently. Reclamation completes this pivot: synchronous calls the handler issued before the event (already dispatched, waiting for the target region's tick) belong to the same plugin and are taken back by the event thread, so "JS is busy with the event" and "the dispatched task has nobody to run it" can no longer block each other.
⚠️ Synchronous-API warning (historical: the sync API was removed in 0.8.0)
Since 0.8.0 the API exposes no synchronous entry points at all (
*Sync, synchronous setters and synchronous property writes are all gone), so the deadlock below cannot be written in normal plugin code. This section is kept as a mechanism explanation: it explains why the removal was necessary, and why the only remaining synchronous form (a hand-written$send) is just as dangerous.The reason is not "slowness" but a self-deadlock: a plugin owns exactly one JS thread, a synchronous call makes that thread block until the call completes, and many game operations fire a server event while completing (
player.teleportSync()firesPlayerTeleportEvent). If this plugin subscribes to that event, the runtime must deliver it to the very JS thread that is blocked and wait for it to complete the event => the event can never be handled => self-deadlock until the 5s timeout (plus a watchdog report on Folia). This is independent of the call site and of the platform (Paper behaves the same way).For the full reasoning — why warnings are not enough, which mitigations we tried and why none suffices, and the costs we accept — see Why the Sync API Is Going Away.
The mechanism: one JS thread + a blocking call + the event it triggers
- A plugin owns exactly one JS thread: every handler, command callback, timer, continuation after
await, worker message callback - evenonLoad/onEnableinitialisation code - is queued on that thread. - A synchronous call blocks that thread (
xxxSync(), a synchronous setter, synchronous property access), waiting for the game thread to finish the operation and return the result. - Many game operations fire a server event while completing -
player.teleportSync(loc)firesPlayerTeleportEvent,performCommandSync(...)firesPlayerCommandPreprocessEvent. This is the key link. - If this plugin subscribes to that event, the runtime must deliver it to that JS thread and wait for it to complete the event (
event.complete). - But that thread is blocked inside step 2, waiting for the call to return => the event can never run => mutual wait until the 5s timeout.
JS thread: blocked inside teleportSync(), waiting for the operation
^ the event fired by that operation must be handled by this thread (the plugin subscribes to playerTeleport)
^ and the event thread is waiting for this thread to complete the event
=> self-deadlock (5s timeout; watchdog report on Folia)The criterion is therefore not "which callback am I in" but "is the event triggered by this sync call subscribed by this plugin". If the answer is yes, it self-deadlocks no matter where the call happens: inside an event handler, a command callback, a timer, a continuation after await, a worker message callback, or onLoad/onEnable initialisation.
Platform-independent
The same holds on Paper. Paper's main-thread pump can only rescue tasks that are executable in place (for example reading e.player.location inside a handler); it cannot rescue the structure where the event must be handled by the JS thread that is blocking itself - the event still has to be queued on that thread.
In-flight reclamation cannot rescue this case (capability boundary)
Reclamation (see event mode) can only rescue one kind of task: one that is already dispatched and legally executable by the current thread. In the self-deadlock case there is no object to reclaim: the event is waiting for the blocked JS thread, not for a queued task.
- reclamation exists for ordinary game calls the JS thread still needs to make during an event;
- it is not a safety net for synchronous calls - do not expect reclamation to make sync APIs safe.
Anti-patterns (all of them self-deadlock or stall the plugin)
| Anti-pattern | Consequence |
|---|---|
Executing a command synchronously anywhere (when subscribed to playerCommand / serverCommand) | same |
Synchronous property writes that fire events (player.health = x, world.time = x, ...) | same |
| Long synchronous batch work inside a callback (thousands of synchronous block writes) | blocks every message of the plugin -> event and heartbeat timeouts |
await on an operation completed by another plugin/service | two-way wait (it has its own event loop), bounded by the timeout |
Holding an event with manualRelease and then synchronously waiting for other services | compound anti-pattern, bounded by the 5s timeout |
What to do instead
// BAD: the sync call fires an event this plugin subscribes to,
// while the only thread able to handle it is blocked on this very line
// GOOD: use the async API; await yields the JS thread so the event can be handled
await player.teleport(loc); // async: does not block the thread
// GOOD: inside a handler do only the necessary, quick async work and complete the event
eventWatch('playerTeleport', async (e) => {
const to = e.to; // take what you need first (owned by this thread)
await player.teleport(to); // async: yields the thread, event can be handled
});- always use the asynchronous API:
await xxx(), callbacks, batch APIs; - the synchronous API is only safe in an extremely narrow case: outside every callback/continuation, and the operation triggers no event this plugin subscribes to, and the target is the current thread - practically impossible to guarantee, so do not use it by default;
- if you do use a sync API, assume it blocks the whole plugin (including the event you are handling).
Unified message frame (since 0.7.0)
| Direction | Shape | Notes |
|---|---|---|
| Uplink | $send(channel, t, p) -> $_send(channel, t, json) | inside init.js, $send hands over to a closure-held $_send (no longer looked up globally); arguments are serialised exactly once |
| Downlink | $hm(t, p, r) | delivered with multiple arguments through QuickJSContext.callHandleArgs, carried by yeow.channel.DownMessage |
- Schedulers do not serialise:
submitGameSyncreturns aCompletableFuture<Object>, and the platform scheduler'sfinish/fail/purge*hand over raw objects; JSON encoding lives in the communication layer only (no double encoding, no gson dependency) - Changing the frame contract requires updating
init.js,PluginThread/WorkerThread/RuntimeCore,yeow-apiand the message specification together
Task submission and task ids
The task id is assigned by core at submission (globally unique, monotonically increasing int64 via TaskIds.next(), passed through the submit signature; platforms must not allocate their own) and carried through enqueue -> dispatch -> reclamation -> completion. Tasks enter one of three pools (HIGH/NORMAL/LOW) and are consumed by the residency cycle or the event thread; dispatched tasks are registered in the in-flight table - see event mode.
Event-bridge specifics (Folia implementation)
| Topic | Handling |
|---|---|
Missing world field | block/explosion events (blockBreak/blockPlace/blockFade/blockGrow/blockSpread/blockExplode/blockIgnite/entityExplode) now carry world, with the nested block/hitBlock object kept in sync |
| Sibling events sharing one HandlerList | ProjectileLaunchEvent/CreatureSpawnEvent/ItemSpawnEvent declare no getHandlerList() of their own, so the whole EntitySpawnEvent family shares one (PlayerTeleportEvent/PlayerMoveEvent likewise) - the listener entry point drops by registered type and warns, removing cross-talk and wasted dispatches |
| Chat events | always AsyncPlayerChatEvent (registering the legacy PlayerChatEvent never fires on Folia) |
| Asynchronous events | dispatched after hopping to the target region/entity scheduler, matching Paper's "hop to the main thread" semantics |
| Entity resolution | the concurrent player map first (Bukkit.getPlayer), then the entity tables; an AsyncCatcher violation degrades instead of aborting the scheduling pump |
| Teleport | player/entity.teleport always goes through teleportAsync (a synchronous teleport on a region thread throws Must use teleportAsync) |
| Block PDC | requires a tile entity (BlockState is not a PersistentDataHolder, only TileState is) - anything else fails with an explicit error instead of silently |
Consequences
- An event blocks only its own region, and reclamation shortens that block: the region's tick stalls while the event thread spins, with the event timeout (5s) as the residual bound; with reclamation the scenario that used to hit both timeouts completes in milliseconds.
- Cross-plugin events run in parallel; events of one plugin are serial (single JS thread).
- Synchronous calls inside an event should touch the event's target: those run in place and return immediately; cross-region calls are dispatched and wait for the target (bounded). Prefer asynchronous APIs (
post) for cross-target work inside handlers. - Cross-region synchronous calls have latency: they run on the target region's thread (next tick, 0-50ms).
- Outside events, synchronous calls benefit from residency: in-place execution in microseconds.
- Global tasks (
server.*) are unaffected by events: they are dispatched to the global region. - Cross-region asynchronous ordering is not guaranteed: use
awaitwhen order matters. - Budget restarts can add up to ~30ms: inherent, not an anomaly.
- Event re-entrancy can deadlock until the event timeout: a synchronous task that triggers a nested event while JS is blocked waiting for it (Paper and Folia alike). Avoid synchronous game operations inside handlers - see Events and callbacks.
Known differences
These are not special-cased and not waived: on Folia the affected assertions fail, and the multi-version matrix surfaces them.
No
/yeowmanagement commands:/yeow load|install|update|unload|uninstall|reload|profile|trackare currently provided only by the Paper-family runtime (FoliaRuntimedoes not register the command). On Folia, deploy plugins by dropping the.yeow.zipintoplugins/Yeow/and restarting the server; porting the command is recorded as a follow-up feature.Scoreboard creation is restricted:
board.createObjective(...)/board.createTeam(...)return explicit errors (Folia only supports reading/modifying existing objectives/teams; everyregisterNewObjective/registerNewTeamoverload throwsUnsupportedOperationException).PlayerTeleportEventis not fired: Folia'sCraftEntity#teleportAsync(and the consoletpbuilt on it) never constructs the event - provable from bytecode (CraftPlayeron Folia mentions only theTeleportCauseparameter type, while Paper referencesisCancelled/getFrom/getTo). The destination remains observable throughPlayerMoveEvent.World#getPlayersis region-scoped: it returns an empty array when the task's region has no players.EntityTargetLivingEntityEventpayloads are unstable: one action fires several events (target change/clear) and some carry anulltarget - do not assert on the payload value.server.getTpsreturnsnull(Folia has no global TPS; plugins must detect unavailability).Global state writes are routed:
world.setTime/setStorm/setThundering/setDifficulty/setSpawnLocation/setGameRulemay only be changed on the global region thread (AsyncCatcher); the runtime routes them automatically.Synchronous teleport is unavailable:
Player#teleporton a region thread throwsMust use teleportAsync; the runtime always usesteleportAsync.Block PDC requires a tile entity:
BlockStateis not aPersistentDataHolder(onlyTileStateis), so writing block PDC on a non-tile-entity reports an explicit error instead of failing silently.
Testing and verification
- Multi-version matrix:
node tests/run.mjs full --mc-versions=matrix(a separate process per version, one report each, plus a summary table); the declarations and required JDKs live intests/e2e/versions.mjs, and--java-home=<dir>selects a JDK. - Full Folia suite:
node tests/run.mjs full-folia(the block bench is off by default;--block-benchforces it). - Reproducing the former deadlock:
node tests/run.mjs full-folia --block-bench --only=e2e-tests- long synchronous block writes while an event fires on the same region. Before the fix:event timeout: entitySpawn … pending=1,task dispatch timed outand a Folia watchdog report; after:block-bench 6 rowswith no watchdog and no timeouts. - Reading watchdog reports:
Tick region … has not respondedmeans a region thread was occupied for too long - in Yeow terms usually an event spin or a long task; pair it with[sched] reclaimlogs (-Dyeow.debug.sched=true) to locate it. - Concurrency caution: server directories are now isolated per version (
tests/e2e/.work/server-<mc>/server-folia-<mc>), so runs of different versions no longer interfere; two concurrent runs of the same version still fight over the runtime jar in that directory (EPERM), and the matrix runs serially. jar (EPERM); the matrix runs versions sequentially.
Reports and failure details
--report=<path>(the old--markdownstill works): writes one complete Markdown report after the run finishes (structured model plus separate renderers; the tier section comes first and the e2e section after, never interleaved with the run log)--details: by default only the failing assertion line is printed; the server log tail and full stack traces appear only when--detailsis passed--outfile=<path>: a plain-text transcript of the terminal output (combinable with--report)
Fake client (cross-version)
- Packet names and fields are confined to an enum (
PACKETS) and payload constructors (payloads), resolved per version and validated against minecraft-data - 1.18.x: commands go through
chatand need the/prefix; 1.19+:chat_command(no prefix) - 1.21.2+: the client must also send the serverbound
player_loaded(minecraft-protocol does not send it; without it newer servers ignore the player's movement and interactions) E2E_CLIENT_TRACE=1prints the fake client's packet sequence and state transitions for cross-version diagnosis
Measured results (2026-09-26)
- Folia's former deadlock scenario (long synchronous block writes plus an event on the same region): before the fix
event timeout: entitySpawn … pending=1+task dispatch timed out+ watchdog; after the fixblock-bench 6 rows(~5.5s) completes with no watchdog and no timeouts full-folia: 117 passed (only the two known platform differences remain: scoreboard objective creation and the missingPlayerTeleportEvent)- Paper
full: 119 passed / 0 failed
FAQ
Q: Why do other plugins' tasks wait while an event is being handled? A: event mode pauses the general dispatch cycle and only the event thread consumes the pools - and it takes only the event plugin's tasks. The rationale is that an event corresponds to a (region, plugin set); concurrent events are naturally separated per plugin. Other plugins' tasks are delayed by a bounded amount (<= the 5s event timeout).
Q: Why does the JS thread not wait forever on a dispatched task? A: dispatched tasks have an entry and a dispatch guard in the in-flight table; when an event fires, SpinPumptakes back and executes the plugin's dispatched-but-not-yet-started tasks (the Folia copy wakes up, sees RECLAIMED and returns at once with its result dropped). "JS waits for the task" and "the event waits for JS" can therefore no longer block each other.
Q: What happens when inflight is full? A: the cycle takes nothing that round (the original spin-drain semantics, with no exemption for in-place tasks) and waits to be woken by completions; reclamation frees slots so progress cannot stall.
Q: Why is there no global TPS on Folia? A: Folia ticks per region and has no global tick loop; server.getTps returns null and plugins must handle the unavailability.
Q: Why is Java 17 mandatory? A: the runtime, the template and the JNI bridge are compiled for Java 17 (class file 61), which is what lets the plugin remapper of older Paper (older ASM) read them; a server below Java 17 refuses to load the plugin with UnsupportedClassVersionError. The reason 1.18-1.20 are still only partially usable is no longer the class version but runtime compatibility with older APIs and the fake client's action timing.
Q: Why are some assertions red on Folia? A: platform differences are not special-cased and not waived: Folia cannot create global scoreboard objectives, teleportAsync does not fire PlayerTeleportEvent, and World#getPlayers is region-scoped. Those checks fail honestly and the multi-version matrix surfaces them.
Q: How do I diagnose Tick region … has not responded? A: the message means a region thread did not return for a long time - in Yeow terms usually an event spin or a long task occupying that region. Use --server-log for the timeline and -Dyeow.debug.sched=true to watch [sched] reclaim id=… lines.
Q: Why does calling teleportSync() inside a playerTeleport handler always time out? A: the synchronous call blocks the plugin's only JS thread, while the event thread is spinning and waiting for that very thread to complete the event; completing the teleport then needs the destination region's tick - the thread the event occupies. The three parties wait for each other until the 5s timeout (reclamation cannot save this case either, see the capability boundary). Use the asynchronous API.
Q: Can reclamation rescue synchronous calls? A: no. It only handles tasks that are already dispatched and whose target is the current thread; "synchronously waiting for a cross-region or inherently asynchronous operation" is outside its reach - there is nothing to take back, because the tick that would complete it is the tick you are occupying.
Platform transparency
- Identical upper API:
yeow-api(Player/World/Event/Command/Worker/fs/http/...) behaves the same on Paper and Folia; event fields, command protocol and MiniMessage semantics are identical. - Platform-independent plugin packages: a
.yeow.zipcarries no platform binding and can move between Paper and Folia unchanged. - Differences live in the runtime: the scheduling model, thread model and task executor are internal details;
getEnv().platformreports"paper"or"folia".
Deployment
Identical to Paper:
- Put the Folia runtime jar (
yeow-runtime-folia-0.8.0.jar) into the server'splugins/; - Put plugin packages (
.yeow.zipor template JARs) intoplugins/Yeow/(auto-scanned) or load them with/yeow load <path>; - Plugin data,
/yeowadmin commands and hot reload behave exactly as on Paper; - The server must run JDK 17+ (the runtime, template and bridge are compiled for Java 17).
For development, npm run dev works as on Paper: point yeow.config.json's paperJar at a Folia build and replace the runtime jar under .yeow/assets/ with the Folia one.