Scheduler & Tasks
Three-level priority scheduler: time-slice budget, auto-demotion, idle spin; fully async API; when to start executing game tasks (onLoad/onInit). Scheduler internals (three-level queue, auto-demotion algorithm, empty-queue spin) are described below in "Scheduler Design".
Task Configuration (TaskOptions)
All API methods that go through the task channel (player.sendMessage, world.setBlock, etc., all async) accept an optional task configuration object at the end of the parameters, which is passed through to the scheduler:
await player.sendMessage('Hello', { priority: 'high' }); // High priority
registerCommand('back', {...}, { priority: 'low' }); // For methods where options is already used, use taskOptions as the third parameterTaskOptions: { priority?: 'high' | 'normal' | 'low' }. A few purely local getters (e.g. player.inventory) do not go through the task channel and need no task configuration.
Async Tasks
Async Operations (Default API)
await player.sendMessage('Hello')
await world.setBlock(0, 65, 0, 'stone')
await entity.teleport(loc)- Submitted via
post()→$sendasync path - Tasks enter the scheduler queue and execute in a future tick
- Does not block JS code — the JS thread can continue processing other messages
- Subject to time-slice budget: if the current tick's HIGH/NORMAL budget is exhausted, the task is queued to the next tick
- Cross-plugin unified scheduling: all plugins' async tasks execute in the same scheduler queue by priority
- Returns a
Promise, which resolves via the callback mechanism upon completion
Sync Mechanism (internal only, not API)
The API exposes only async tasks; the runtime's synchronous request/response mechanism is used solely by the framework's own control frames. Plugins that need to send their own messages can hand-write $send(channel, t, p) (frame contract: platform spec, "message channels").
Design background: Why the sync API was removed.
Practical guidance:
| Scenario | Approach |
|---|---|
| Bulk repetitive work (filling blocks, batch ops) | await loop or postBatch |
| Reading several fields at once | await x.snap() (read-only snapshot) |
| Work that must finish within one tick | express it as one task at a time (await per block); there is no "sync block" |
| Cancel/modify an event inside a handler | assign directly (e.cancelled = true) — collected locally, no round trip |
Batch Tasks
Use postBatch to submit several independent tasks once and get all results back — fewer round trips:
import { postBatch } from 'yeow-api';
await postBatch([
{ type: 'player.sendMessage', params: { uuid, message: 'A' } },
{ type: 'player.sendMessage', params: { uuid, message: 'B' } },
]);- Tasks run one by one, not atomically (a failure does not abort the rest); a failed item is
{err} - Each item may carry
priority(high/normal/low) - Batching does not change task semantics — it only removes JS↔runtime round trips
Selection Guide
| Scenario | Recommendation |
|---|---|
| Sending messages, broadcasting, setting blocks | Async (default) |
| Reading data (health, position, world time, ...) | await p.getHealth() / await world.getTime(); await p.snap() for several fields |
| Cancelling/modifying an event in a handler | plain assignment (e.cancelled = true, e.deathMessage = ...) — local, no round trip |
| Command executors | async (async executor) |
| Operations that must complete as soon as possible | async + priority: 'high' |
| Several independent operations | async batch postBatch |
| Executing console commands | await dispatchCommand(...) |
Assigning to event fields is not a game task call — it only collects values into the write-back payload (
mods), delivered once byevent.complete. It is inherently local and needs no replacement.
Scheduler Design
Yeow's scheduler uses a three-level priority queue + time-slice budget + auto-demotion architecture. All plugins' game tasks are scheduled uniformly.
Three-Level Priority Queue
┌──────────────────┐
│ HIGH Pool │ ← High priority (`priority: 'high'`)
├──────────────────┤
│ NORMAL Pool │ ← Default priority (when no `priority` is given)
├──────────────────┤
│ LOW Pool │ ← Auto-demoted or manually set to `low`
└──────────────────┘Per-tick execution flow:
tick main-thread pump called every 50ms:
① First round (allocated by budget proportion)
HIGH 50% budget → if unused, overflows to NORMAL
NORMAL 30% budget → if unused, overflows to LOW
LOW 20% budget → no overflow, stops when exhausted
② Greedy phase
Ignores per-tier independent budgets, follows HIGH → NORMAL → LOW order
Executes as many as possible until deadline is exhausted or all queues are empty
③ Idle spin
After queues are empty, if deadline time remains, enters spin-wait (default 100μs)
During spin, continuously checks all three queues; new tasks immediately enter the greedy phasePer-tick total budget defaults to 20ms (tick-budget-ms: 20), configured in plugins/Yeow/runtime/config.yml. Tasks exceeding the deadline are queued to the next tick.
Priority parameter: passed in at the API layer via
TaskOptions(see "Task Configuration" above); the underlyingpostalso accepts legacy string priorities ('high'/'normal'/'low').
Auto-Demotion Algorithm
Auto-demotion prevents high-frequency NORMAL tasks from starving other tasks' execution time.
Demotion condition:
When a NORMAL task is submitted
→ Check the call frequency of that plugin:taskType in the last 1 second
→ If it exceeds the threshold (default 200 times/second), demote priority to LOW
Demotion timing:
Frequency is checked at submission time (submitGameSync/submitGameAsync),
determining whether the task enters the NORMAL or LOW pool.
Once enqueued, re-checking does not occur at execution time.
Recovery:
When the frequency decreases, NORMAL tasks of that taskType are no longer demoted,
naturally returning to the NORMAL pool for execution.The frequency tracker TaskFrequencyTracker uses a sliding window algorithm:
1 second = 50 time slots, each 20ms
On each NORMAL task call:
① Calculate the time slot index for the current time
② If the slot is expired (>20ms since last update), reset to 0
③ Increment the slot counter
④ Sum all slot counts across the past 1 second
⑤ If total exceeds the threshold (default 200), return true (should demote)The sliding window approach offers these advantages: no historical data retention, fixed 1-second window size, constant memory footprint (50 ints + 50 longs), and fast, stable computation.
Empty Queue Extra Wait Mechanism
idle-spin-us: 100 (maximum wait time, default 100 microseconds) addresses the following scenario:
JS thread submits task → Main thread just finished clearing the queue ← Time gap < 0.1ms
↓
Without spin: waits 50ms for next tick
With spin: detects the new task within 100μs and executes immediatelyOur empty-queue extra wait mechanism can, in the vast majority of scenarios, effectively improve resource utilization efficiency for Yeow plugins in certain common situations (up to 500x) while keeping server load stable.
For example:
for(loc of locs){
await world.setBlock(...loc, blockType);
}Without the empty-queue extra wait mechanism, when Yeow scheduler load is low, the task execution efficiency of the above code is (1 task/tick, 20 task/s); with the empty-queue extra wait mechanism, task execution efficiency can reach (10000+ task/s).
Sync Call Flow (internal)
$send('task', 'player.getPing', { params /* no cb */ })
→ PluginThread: scheduler.submitGameSync(taskId, …, future)
→ future.get(task-sync-timeout-ms) [JS thread blocks]
→ game thread tick(): pops from the priority pool → Tasks.execute()
→ future.complete(result) [JS thread resumes]Not plugin API:
*Syncmethods and sync accessors are gone (see Why the sync API was removed). Only framework control frames and the hand-written$senduse this path.
Async Call Flow
post('player.sendMessage', {...})
→ $send('task', '...', {..., cb:"cb_1"})
→ PluginThread: scheduler.submitGameAsync(pld, cbId, callback, priority, name)
→ Returns immediately [JS thread not blocked]
→ Main thread tick(): executes → callback.accept(result)
→ queue.sendJs($hm(t="cb", p="cb_1", r=result))
→ Message loop receives → Promise resolvefs / http / assets
These do not go through the main thread; PluginThread handles them directly:
$send('fs', 'readFile', {"path":"config.json"})
→ PluginThread.handleFs() → java.nio.file
→ Returns synchronously
$send('assets', 'read', {"path":"assets/config.a1b2c3d4.yml"})
→ PluginThread.handleAssets() → ZipFile reads the corresponding entry inside the JAR
→ Returns synchronouslyPath safety: plugin-level access is restricted to plugins/<pluginName>/ and blocks ../ traversal; server / outer levels require the corresponding permissions (see the fs specification).
Resource namespaces: at build time, each dependency (the main project and qualifying npm packages) is assigned a unique namespace id for its assets/, and contents are copied verbatim into the JAR assets/<id>/ (no hash renaming, so relative references are always valid). The JS side should always use getAssetsPath() (from yeow-dev) to obtain paths rather than hardcoding. See Assets API.