Call dispatch, threading, timing, and batching

Execution

How method calls travel from Python to Java, get executed, and return results. Covers threading, the thread-safety allowlist, timing characteristics, and call batching.


Call Flow

When Python calls a Java method (e.g. player.get_health()):

1. Python sends the call

# Inside BridgeConnection.call()
message = {{"type": "call", "id": 1, "method": "getHealth", "handle": 42, "args_list": []}}
# If inside a batch context: queued to _batch_messages
# Otherwise: sent immediately over stdout

The BridgeCall wrapper (an asyncio.Future) is returned so the caller can await the result.

2. Java bridge thread reads the message

The bridge thread (one per script) reads from Python's stdout in a blocking loop. Each message is deserialized (from msgpack or JSON, depending on the negotiated format) and passed to handleMessage(), which routes by type.

3. Thread safety check

isCallThreadSafe(message)?
├── YES → Execute immediately on bridge thread
│         Response sent directly (sub-millisecond)
└── NO  → Queue to main thread via MinecraftServer.execute()
          Response sent after main thread processes it

4. Method resolution

Java resolves the call through a dispatch chain:

  1. Type-specific handlers — checked first. Player, World, Server, ItemStack, Inventory, Block, Display each have custom method handling for common operations (e.g. playSound, teleport, spawnEntity)
  2. Special methodsget_attr (field access), set_attr (field set), close (no-op cleanup)
  3. Reflective fallback — if no handler matched, Java reflection finds a matching method by name and parameter count, with automatic argument type coercion

5. Python receives the response

The reader thread reads the response from stdin:


Threading Model

Bridge thread (per script)

Each script gets a dedicated Java thread that:

Thread-safe calls never leave this thread, which is why they're sub-millisecond.

Main server thread

The Bukkit main thread runs the game loop at 20 TPS (50ms per tick). Non-thread-safe calls must execute here because most Bukkit API methods are not thread-safe.

Sub-tick scheduling

Non-thread-safe calls are dispatched via MinecraftServer.execute() (resolved at startup via reflection). This adds the task to Minecraft's internal processQueue, which is checked during the server's idle sleep between ticks. The server wakes up via LockSupport.unpark() to process the task immediately.

If MinecraftServer.execute() is unavailable (non-Paper server or reflection fails), calls fall back to a ConcurrentLinkedQueue drained once per tick by a runTaskTimer.

Spin-wait drain

After executing a main-thread task, the drain loop spins for up to 5ms checking for follow-up calls. This catches chained sequential calls (Python sends the next call immediately after receiving a response).

Constants:

After processing a task, the spin deadline resets. But total spin time per drain cycle is capped at 20ms to prevent starvation under continuous load.


Thread-Safe Allowlist

These calls execute immediately on the bridge thread with no tick wait:

Target / TypeSafe Methods
metrics facadeALL methods (reads atomic/volatile values)
reflect facadeALL methods (Class.forName() is thread-safe)
server facadegetName, getVersion, getBukkitVersion, getMaxPlayers
OfflinePlayer / PlayergetUniqueId, getName, hasPermission, isPermissionSet, isWhitelisted, isBanned
Metadatable objectshasMetadata, getMetadata
Entity (non-Player)getUniqueId
Object handle releasesAlways (backed by ConcurrentHashMap)

Everything else runs on the main server thread. The allowlist is intentionally conservative — calling a non-thread-safe method from the wrong thread can cause data corruption or crashes.


Timing

Thread-safe calls

Executed inline on the bridge thread. Typical latency: < 1ms.

Non-thread-safe calls (first in chain)

Must wait for the main thread. With MinecraftServer.execute(), typically 1–10ms. Without it (fallback queue), up to ~50ms (one full tick).

Non-thread-safe calls (chained)

The spin-wait catches follow-up calls within 1–3ms each.

Example timing scenario

name = await server.name           # Thread-safe → ~0.5ms
health = await player.get_health() # Main thread → 1-10ms (first call)
await player.set_health(20)        # Fire-and-forget → ~0ms (no response wait)
await player.send_message("Hi")    # Fire-and-forget → ~0ms (no response wait)

Without fire-and-forget, each void call would pay a full round-trip. With it, setters and void methods return instantly.

Why the first call is slower

The main thread runs a game loop: process tick → sleep until next tick. When a call arrives mid-sleep, MinecraftServer.execute() wakes the thread, but it still needs to context-switch and begin processing. The first call pays this wake-up cost. Subsequent calls arrive while the thread is already running, so they're processed immediately.


Sync vs Async Calls

Async (default)

name = await player.get_name()  # Returns BridgeCall wrapping asyncio.Future

Sync (internal use)

name = player.name  # Property access calls call_sync() internally

Rule of thumb: Use await methods in async code. Property access (sync) is fine in top-level code or synchronous contexts.


Batching

Batching groups multiple calls into a single wire message, reducing round-trip overhead. Instead of send-wait-send-wait, all calls are collected and sent together.

server.frame() — Non-atomic batch

Each call is independent. If one fails, the rest still execute.

async with server.frame():
    name = await player.get_name()        # Queued
    health = await player.get_health()    # Queued
    world = await player.get_world()      # Queued
# All 3 sent as one call_batch, results awaited together

Wire message:

{{
  "type": "call_batch",
  "atomic": false,
  "messages": [
    {{"type": "call", "id": 1, "method": "getName", "handle": 42, "args_list": []}},
    {{"type": "call", "id": 2, "method": "getHealth", "handle": 42, "args_list": []}},
    {{"type": "call", "id": 3, "method": "getWorld", "handle": 42, "args_list": []}}
  ]
}}

Each call gets its own return response (3 separate messages back).

server.atomic() — Atomic batch (best-effort)

If any call fails, all subsequent calls are skipped (not rolled back — already-executed calls are not undone).

async with server.atomic():
    inv = await player.get_inventory()
    await inv.clear()
    for item in items:
        await inv.add_item(item)
# If any add_item fails, the rest are skipped

On failure: The failed call gets a normal error response. All remaining calls get an error with code ATOMIC_ABORT:

{{"type": "error", "id": 5, "message": "Atomic batch aborted", "code": "ATOMIC_ABORT"}}

Nesting

Batches can nest. The outermost context manager triggers the flush. If any level is atomic, the entire batch becomes atomic:

async with server.frame():
    await player.get_name()
    async with server.atomic():
        await player.set_health(20)
        await player.set_food_level(20)
    # Inner atomic doesn't flush yet
# Everything flushes here as atomic=true (because inner was atomic)

Batch thread safety

If all calls in a batch are thread-safe, the entire batch executes on the bridge thread. If any call is not thread-safe, the entire batch goes to the main thread.

When to use batching


Fire-and-Forget Calls

Void methods and setters don't need a return value. Fire-and-forget skips the response entirely, eliminating serialization and round-trip overhead.

How it works

  1. Python sends the call with "no_response": true
  2. Java executes the method normally
  3. Java skips serializing the result and sending a response
  4. Python never creates a Future or Event — the call returns immediately

Wire message

{{"type": "call", "id": 1, "method": "setHealth", "handle": 42, "args_list": [20.0], "no_response": true}}

No response is sent back. The id is still included for debugging/logging purposes.

Which methods use it

~80+ methods on Entity and Player are fire-and-forget, including:

Batching support

Fire-and-forget works inside frame() and atomic() batches. Each call in a call_batch can independently have no_response, and Java skips responses only for those specific calls.

Cache invalidation

Setters that change cached proxy fields call _invalidate_field() before sending the fire-and-forget, ensuring the cache is cleared and the next read fetches fresh data. See Serialization — Field cache invalidation for details.


Wait Mechanism

server.after(ticks) pauses execution for N server ticks:

await server.after(20)  # Wait 1 second (20 ticks)

Implementation: Python sends {{"type": "wait", "id": N, "ticks": 20}}. Java schedules a runTaskLater on the Bukkit scheduler. When the delay expires, Java sends {{"type": "return", "id": N, "result": null}}. The Python future resolves and execution continues.

This is the correct way to introduce delays — it integrates with the server's tick system rather than using asyncio.sleep() which would be wall-clock time and might not align with server ticks.