Why the Sync API Is Going Away
Status: implemented in 0.8.0 (every public synchronous entry point is gone; the underlying mechanism stays).
This page records the reasoning, and serves as the guard against reintroducing it. It has two parts:
- A category of semantics that cannot be implemented correctly in this model - the sync API constructs cross-thread wait cycles. We have already accumulated a growing set of fallbacks for them, and every new class of cycle can only be mitigated by a platform-specific trade-off.
- It also closes off the runtime's evolution space - an entirely asynchronous API decouples the runtime implementation from the transport medium, so in development mode a plugin's JS could run on Node.js (V8) and full debugging would come with it. The synchronous entry points make that impossible.
Its purpose is to stop future readers (including ourselves) from concluding that "a louder warning, or one more watchdog, makes the sync API viable again".
Related: Scheduler & Tasks - Events & Callbacks - Folia Support - Runtime Warnings
1. The decision (implemented)
- Remove every synchronous entry point in the API surface:
*Syncmethods, synchronous property access (getters / setters), and the synchronous batchcallBatch. (Done in 0.8.0: 127 distinct*Syncnames / 140 declarations, 81 accessors,callBatchand thecall()export are gone;registerCommand/registerPermissionreturn aPromise, andeventWatchreturns a subscription handle carryingready.) - Keep the underlying synchronous request/response mechanism as an internal implementation detail. Only two internal users remain: framework control frames (
event.complete/command.tabComplete, now sent withnoReply) and hand-written$send. - Specified entry point:
$send(channel, t, p)and the task channel protocol are guaranteed by the platform specification (frame format,cbsemantics and "nocbmeans synchronous" are all part of it). We merely stopped offering synchronous sugar in the API — because it builds wait cycles, not because it is unstable; a hand-written synchronous$sendcarries exactly the same self-deadlock hazards as the old*Sync(see section 7).
2. Starting point: two structural facts
- A plugin owns exactly one JS thread; every handler, command callback, timer,
awaitcontinuation and worker message is queued on it. - A synchronous JS section cannot be split: once the thread is blocked inside a sync call, it cannot process any queued message.
Every conclusion below follows from those two. Note in particular that "execute tasks while spinning" is not a fallback invented for the sync API - it is the precondition for "operating on the game from inside an event handler" at all, and the async API depends on it just as much. That is the key to understanding the difficulty differences in the three sections that follow.
3. Executing tasks while spinning: what it solves, and what it cannot
3.1 The basic mechanism: executing tasks while an event is being handled
While the event bridge waits for a plugin to hand the event back (event.complete), it does not sit idle: it spins, and executes game tasks from the scheduler pools while spinning.
Game thread: dispatch event -> deliver to the plugin's JS thread -> spin waiting for event.complete
|
+-- while spinning, execute pooled game tasks
JS thread: handle the event -- (may issue game operations) ------> event.completeWhy it is required (and why it has nothing to do with sync vs async): event dispatch happens on the game thread, and at that moment the game thread is no longer running its per-tick pump. If the spin did not execute tasks, then any game operation issued inside a handler - including a purely asynchronous await world.setBlock(...) - would become a mutual wait ("the task queues with nobody to run it / the event waits for JS to hand it back"). Therefore:
Executing tasks while spinning is the ability to operate on the game during event handling. It is not a sync-API fallback.
Paper's implementation is PaperScheduler.drainDuringWait(): it drains the HIGH / NORMAL / LOW pools in priority order. Those three pools are global (they are not partitioned per plugin) - a detail that becomes the decisive difference in the next section.
3.2 Interleaved deadlock - it can be caught
Scenario: plugins A and B submit synchronous tasks at almost the same moment; A's task fires an event B subscribes to; B's JS thread is blocked in its own sync call waiting for a result.
On Paper this can be caught. Because the spin drains the pools of all plugins, B's task - the one "still in the pool" - is executed in place by the very game thread that is spinning => B's sync call returns => B's JS thread resumes => it can handle that event => the cycle is broken.
This is the most elegant payoff of the spin mechanism: a mechanism that has nothing to do with that plugin incidentally unblocks another plugin's wait.
It holds under a chain of preconditions: the awaited task must still be pooled (not yet dispatched), it must be legal to execute on the current thread, and no step of the dependency chain may require something only the JS thread can produce. The first two normally hold on Paper - when the third fails, we are in the next section.
3.3 Event reentrancy deadlock - hard to handle
When the dependency lands on the blocked JS thread itself, draining the queue is powerless: the thing being awaited can only be produced by the thread that has already stopped, and there is no "reclaimable object" in the pool.
(1) Mutually waiting with "the event it triggers" (the most intuitive case)
eventWatch('playerTeleport', (e) => {
player.teleportSync(somewhere); // 5 seconds of deadlock, immediately
});teleportSync() blocks the JS thread -> the game thread, while executing that task, fires another PlayerTeleportEvent -> that nested event must be handled by the blocked thread => it can only end in a timeout. The cost: the nested event's write-back payload is dropped, the main thread spins for 5s, a heartbeat warning fires, and on Folia a watchdog report follows.
Note that the outer, original event does eventually finish - the casualty is the event that the sync call itself produced, which is precisely the one the plugin cares about. "The most natural thing to write, the most inexplicable outcome" is exactly what makes this so damaging.
(2) Mutually waiting with "something only the JS thread can produce" (subtler, and structurally unsolvable)
Permission checks are a ready-made counter-example (PlayerTasks.hasPermission -> EventBridge.checkPermission):
onPermissionCheck((check) => { /* ... */ });
player.hasPermissionSync('myplugin.use'); // same plugin, any locationhasPermissionSync blocks the JS thread -> the game thread executes it -> it fires permission check (onPermissionCheck) -> the event is dispatched to its subscribers, including the caller -> and waits for that JS thread to return => the full 5s timeout is guaranteed.
Here "execute tasks while spinning" cannot help at all: the event is waiting for the blocked thread itself, not for some pending task. This class cannot be fixed by a cleverer scheduler - it follows directly from "a synchronous JS section cannot be split".
3.4 Folia - harder still
Folia has no global main thread, so the premise "just execute tasks while spinning" does not hold: a resource bound to a region can only be operated on from that region's thread. The same need therefore has to be met differently - by reclaiming tasks.
What the reclaim mechanism means: take the plugin's tasks that are "dispatched but not yet started" and run them here instead. Execution rights are arbitrated against the region scheduler's delivered copy with an atomic state (PENDING -> RUNNING / PENDING -> RECLAIMED; only the CAS winner runs the body), so double execution is impossible by construction.
Crucially, it only reclaims the tasks of the plugins involved in the current event - the "pure L scheme", justified by the "one JS thread per plugin" pivot: in event mode only the subscriber set of the current event is drained (for tab completion, only that one plugin). Filtering is a set test on plugin names (string matching).
The payoffs of the L scheme are real:
- It exploits a plugin's spatial locality - the same plugin repeatedly operating on the same region (a hotspot);
- Filtering is cheap: a set test on plugin names, with no need to parse coordinates of every candidate task to decide ownership;
- Folia's concurrency is preserved: no migration, no stealing work from other region threads, other regions keep ticking in parallel.
But its price lands exactly on the problem we most want solved:
- Interleaved deadlock is no longer caught. Paper solves 3.2 because it drains every plugin's pool; the L scheme only reclaims the tasks of the plugins involved in the current event. So "two plugins submitting tasks about the same region" turns back into a 5s timeout - the blocked party is not in the current event's plugin set, so it cannot be reclaimed.
- Abandoning the L scheme does not buy a solution - it only loses three things at once:
- Concurrency: to be able to "execute an arbitrary task in place", you must give up the constraint of only touching your own region;
- Higher filtering cost: deciding whether a task may run on the current thread requires calling
ownedHere(params)per candidate task, degrading from "one set test" to "a per-candidate coordinate decision"; - And cross-region candidates still cannot be executed in place - so after paying all of that, some cases remain unsolvable.
And this is only one example. The real problem is not "Folia is missing a fallback", but that every platform and every threading model re-flattens the same set of semantics into a new trade-off table - where the gains are, where the costs are, which class of cycle is caught and which is abandoned. We are paying the maintenance cost of that table forever, just to keep the sync API barely usable.
A corollary (and one more reason to remove it): interleaved deadlock is a sync-only phenomenon.
The "interleaving" of 3.2 and 3.4 only exists because some plugin's JS thread is blocked waiting for its own sync task. Once everything is asynchronous, no JS thread ever blocks on a game task - it merely awaits, the task stays queued and is picked up by the normal cycle or by an event spin, and there is no cycle to close.
It follows that after the sync API is removed, the only things that ever need to be "executed in place" during an event/completion are the framework's own control frames (event.complete / command.tabComplete) - and those necessarily come from the plugin set of the current event/completion, which is exactly what the L scheme covers.
In other words:
Once the sync API is gone, the L scheme becomes sufficient again, and we no longer have to give up Folia's concurrency (and pay an extra round of
ownedHerefiltering) in order to catch cross-plugin interleaving. The "platform trade-off table" is not something we force ourselves to swallow - it largely ceases to exist along with the sync API.
That also answers the original question - "why can't we keep adding fallbacks?" - because the kinds of fallback that exist were forced into being by the sync API. Take the semantics away and the problem space itself shrinks.
3.5 Two additions
(1) The framework itself stands inside the sync section. This is not "plugin authors misuse the API":
| Framework-internal call | Form | Semantics |
|---|---|---|
event.ts releases an event | $send('task','event.complete',{..., cb:''}) | empty cb => synchronous block, up to task-sync-timeout-ms (10s) |
command.ts releases a tab completion | $send('task','command.tabComplete',{..., cb:''}) | same |
command.ts adapts the sender | Player.getSync(uuid) | synchronous game task (the payload already carries name/uuid, so zero round trips are possible) |
command.ts / event.ts / permission.ts | call('command.register' / 'event.subscribe' / 'permission.register') | synchronous game task |
util.ts's toB64() | synchronous send() | called from inside async Gzip.compress/decompress |
fs.ts streaming UTF-8 decode | bytesToStringSync() | called per chunk inside the async read stream |
Two consequences: (i) every event callback ends with the JS thread blocking on a game-thread round trip for event.complete, regardless of how careful the author is; (ii) deleting the *Sync methods does not remove the synchrony - it has leaked into async implementations. Conversely, this work pays for itself: event.complete / command.tabComplete could simply be fire-and-forget notifications (the game thread is waiting for the event anyway and will execute them during its spin), which would stop the JS thread from parking once per event.
(2) The runtime's health machinery reads a sync call as a hung plugin. Warnings and self-recovery are heartbeat-based: each window a DEBUG:ping is posted and a pong is expected. A thread blocked in a sync call cannot receive or answer it, so a stall caused purely by ordinary use of the sync API is classified as:
| Symptom | Threshold (default) | Consequence |
|---|---|---|
| No pong within a window, or round trip > 200ms | latency-warn-threshold-ms | heartbeat.timeout WARN |
| No response for a sustained period | suspend-warn-seconds 30s | plugin.hung SEVERE |
| Still unresponsive | auto-reload-hung-seconds 120s | the plugin is auto-reloaded |
The remediation text those detectors print literally says "avoid long synchronous operations; switch to async APIs or chunk the work". In other words: the runtime tells plugins not to use the sync API, while half the API surface is synchronous - including the parts that cannot be written asynchronously: the 81 property accessors. None of the 65 @yeow-sync-warning sites covers them, yet bar.progress = 0.5 is a blocking game-thread call. One of them is easy to miss: event payloads carry no player name, so Player.name performs a lazy synchronous fetch - eventWatch('playerJoin', e => log.info(e.player.name)) blocks on a game-thread round trip the first time it runs. It cannot deadlock, but it proves the point: as long as a synchronous entry point exists, it will show up in everyday code.
4. Mitigations we tried, and where each one ends
| Mitigation | What it solves | Where it ends |
|---|---|---|
Paper drainDuringWait(): while spinning, drain the three pools of all plugins plus the fallback queue | (1) Makes "operating on the game from inside a handler" possible at all (async depends on it too); (2) catches interleaved deadlock (3.2) | Requires "the task is still pooled and legal to run here"; useless for event reentrancy (3.3, which depends on the blocked thread itself) |
| Event timeout (5s) / task timeout (10s) | Downgrades "permanent deadlock" to "bounded stall" | During the stall the main thread spins, the payload is dropped, warnings and even auto-reload follow. A tourniquet, not a cure |
| Folia task reclaim + the L scheme: reclaim the "dispatched but not started" tasks of the plugins involved in this event and run them in place (CAS arbitration prevents double execution) | Lets handlers operate on the game from a region thread; preserves concurrency and cheap filtering | Only those plugins' tasks are reclaimed => interleaved deadlock returns (3.4). Abandoning L costs concurrency and adds ownedHere filtering, and cross-region tasks still cannot run in place |
concurrent-events (on by default) | Shortens total time for serial multi-plugin dispatch | Unrelated to the root cause ("one JS thread is blocked") |
"If a sync task exceeds 100ms and the submitter has a pending event, return err immediately plus a Possible Deadlock warning" | Would at least let the plugin see an error and consume events from its queue | (1) Requires touching the scheduler, the event bridge and both platforms; (2) even more intricate on Folia; (3) still a symptom fix - payloads are still dropped and ordering is still wrong; (4) does not cover the next class after 3.3/3.4. Evaluated and rejected |
@yeow-sync-warning (65 sites) plus prose warnings across the docs | Signals that a risk exists | Covers only the sync methods that fire events; the 81 synchronous property accessors carry no warning at all; and the docs contradict each other (below) |
The documentation contradicts itself, which is itself evidence that "remember the ban" does not work:
- getting-started.md: "Do not use the sync API (anywhere, not just inside event callbacks)."
- folia.md: "no serious project should use the synchronous API in a Yeow plugin"
- scheduler.md: "A small number of sync API calls won't cause issues", with a recommended-practices row reading "operations that must complete within a single tick | the sync API is fine"
The same doc set says "never use it" in one place and lists it as a recommended tool in another - because whether "a small number" is safe genuinely depends on whether that call site happens to close a cycle, and a cycle can be created by a third plugin's subscriptions, which the author cannot see.
So the loop repeats:
find one class of cycle -> add a fallback (and accept a new set of trade-offs) -> complexity grows -> the next class appears (a different shape, or a different platform) -> add another fallback -> ...That is the core motivation for this work: these problems keep emerging, and none of them can be solved elegantly - unless every task is special-cased one by one, which we refuse: it is neither maintainable nor provable.
5. The second reason: it closes off the runtime's evolution space
Even setting deadlocks aside, the sync API is an architectural obstacle.
If the API is entirely asynchronous, then all that remains between the JS runtime and the host is "send a message / receive a message" - the channel is pure data. At that point:
- Switching execution media is just a thin adapter: in development mode the plugin's JS could run on Node.js (V8);
- And then full debugging (breakpoints, stepping, variable inspection, call stacks, profiling) becomes available immediately - exactly what the "runtime debugger" item on the roadmap wants, without us writing a QuickJS debugger;
- Unit tests could run in plain Node, with no Minecraft server involved.
The sync API is what closes that door. It demands "call from JS, get the result immediately", i.e. it demands that the host's execution thread can be driven on demand. Those "run queued work while spinning" tricks exist precisely to make that requirement hold inside a single address space. As soon as a pipe / socket / process boundary is involved, the contract is unimplementable: either spawn a thread per call and block on it (degenerate and expensive), or busy-wait (worse).
Put differently: the sync API couples the runtime implementation to the transport medium, while our goal is the opposite - a plugin package is a plain ZIP and any runtime implementing the platform specification should be able to run the same package. The synchronous entry points are a substantive obstacle to that goal.
Why it is worth paying the removal cost for it: debugging capability, a choice of runtime implementations and platform portability are multiplicative abilities; what the sync API buys is a small per-round-trip latency saving per plugin (see section 6). Over the long run the trade is clear.
6. The costs (accepted, and stated plainly)
6.1 Higher latency per operation
Without the sync API, "submit -> execute -> post back -> resume the microtask" must cross at least one message hop. In-repo benchmarks (tests/e2e/plugins/e2e-tests/src/suites/bench.ts and blockbench.ts, reproducible with node tests/run.mjs full):
| Payload | Sync | Async | Ratio |
|---|---|---|---|
debug.payload empty (p50, recorded in-repo) | 0.003 ms/op | 0.012 ms/op | ~4x |
world.setBlock (mean, measured locally) | 0.047 ms/op | 0.077 ms/op | ~1.6x |
But two different things are being measured:
- Per-call round-trip latency does get worse (the table above). That is the price we accept.
- Throughput need not get worse: a sync call is strictly serial by construction (one in flight, and every call parks/unparks a thread), whereas async can submit several independent operations within one tick (
Promise.all/postBatch). Thebatchrow of the in-repoblockbenchis the evidence: batched submission is faster than calling synchronously in a loop.
So "peak performance drops" is more precisely: the latency of a serially dependent chain rises; in parallelisable or batchable work, async can be faster. What genuinely must improve is the ergonomics of async batching (
postBatchalready exists; removingcallBatchrequires updating docs and examples with it).
6.2 Losing the property-style API
p.health / p.location / bar.progress = 0.5 cannot be asynchronous (a TS property access cannot return a Promise without breaking intuition). Migration shapes:
| Today | After |
|---|---|
p.health | await p.getHealth() |
e.player.name | event payloads pre-fill the player name (so e.player.name becomes a plain field read, zero round trip) |
bar.progress = 0.5 | await bar.setProgress(0.5) |
world.time = 6000 | await world.setTime(6000) |
Material.isSolidSync(t) | await Material.isSolid(t), or better: a purely local lookup over an already cached material table |
Note the optimisation available here: getMaterials / getBlocks / getItems are already module-level caches, so material-level static predicates can degrade to zero-round-trip local lookups instead of async round trips.
The one exception: assigning to event fields (e.cancelled = true, e.deathMessage = ...) involves no cross-thread round trip at all - it merely collects the value into the write-back payload (mods), which event.complete hands back once when the event ends. It therefore still works unchanged under the async model and needs no replacement.
6.3 Breakage and migration cost
- This is a breaking change, recorded in
changelog.md(scope, replacements and migration guidance below). - It must be accompanied by updates to the API implementation and docs, the
tests/e2escenarios (e.g.blockbenchusesgetTpsSync;scheduler.mddocumentscallBatch), and every statement that "the sync API is fine here".
7. After removal: scope and specified entry point
What is removed
- Every
*Syncmethod (~140 of them); - Every synchronous property accessor (81, across
player/entity/world/bossbar); callBatch(its asynchronous counterpartpostBatchstays).
What is kept (the mechanism)
The underlying synchronous round trip remains, and the framework keeps using and refactoring it (for example turning event.complete / command.tabComplete into non-blocking notifications). It is simply no longer a public API.
Note that drainDuringWait / task reclaim / the L scheme will not disappear when the sync API is removed - they are first of all the precondition for "operating on the game during event handling" (3.1). After the removal the set of things they must serve is simpler (only the framework's own control frames and the async model), but they cannot be deleted.
Specified entry point: hand-written $send
WARNING
A hand-written $send is a specified entry point, but the synchronous form's hazards are yours.
$send(channel, t, p) and the task channel protocol are part of the platform specification (see message channels): channel names, frame format, cb semantics and "no cb means synchronous" are all specified behaviour, and changing them is recorded as a breaking change. We do not offer API-level synchronous sugar because it builds wait cycles — not because it is unstable. If you write it yourself, know that:
- It blocks the plugin's only JS thread, freezing everything in that plugin: events, tab completions, timers and
post()results all stop being processed. - If the operation fires an event this plugin subscribes to, or depends on anything only that JS thread can produce, it will wait out the timeout (5s for events, 10s for tasks). Meanwhile the main thread spins, the event payload is dropped, and
heartbeat.timeout->plugin.hung-> (on by default) auto-reload follow. - We will not add new fallbacks for its deadlocks. The whole point of this decision is to stop growing complexity for it.
If a piece of logic "must be synchronous", the correct move is almost always to redesign it as asynchronous: prefetch the data, put the result into the event payload, or submit in batches with postBatch - rather than hunting for a synchronous entry point.
8. Evidence index (for verification)
| Claim | Location |
|---|---|
Empty cb means a synchronous block (so event.complete / tabComplete are blocking) | RuntimeCore.java, submitTask (the hasCb decision) |
| While an event/completion spins, tasks of all plugins are drained (3.1 / 3.2) | paper/.../PaperScheduler.java (drainDuringWait drains the three pools plus the fallback queue), paper/.../EventBridge.java, paper/.../CommandBridge.java |
| Permission checks dispatch back to the caller (the 3.3 counter-example) | paper/.../task/PlayerTasks.java, hasPermission -> EventBridge.checkPermission |
| The L scheme: only the plugins involved in this event are drained (3.4) | folia/.../FoliaEventBridge.java (SpinPump.spin(..., active.keySet(), ...) / subscribed), folia/.../FoliaCommandBridge.java (Set.of(pluginName)), folia/.../FoliaScheduler.java (drainForPlugins / pollForSpin) |
| Reclaiming "dispatched but not started" tasks + CAS arbitration of execution rights | folia/.../FoliaScheduler.java (reclaimForPlugins, GuardedFinish), folia/.../SpinPump.java |
| Heartbeat / hung / auto-reload thresholds | core/.../YeowConfig.java; core/.../profile/warnings/detectors/{HeartbeatTimeoutDetector,PluginHungDetector}.java |
| A sync call blocks message processing | core/src/main/resources/js/init.js (the message loop) |
| Where the framework itself uses the sync primitive | yeow-api/src/{event,command,permission,util,fs}.ts |
Size of the sync surface (*Sync / accessors / warnings) | yeow-api/src/*.ts (65 @yeow-sync-warning sites) |
| Latency and throughput benchmarks | tests/e2e/plugins/e2e-tests/src/suites/{bench,blockbench}.ts (node tests/run.mjs full output) |