Overview of the bridge architecture and wire protocol

Architecture

This section describes how PyJavaBridge works under the hood. Start here for the big picture, then dive into specific topics.


Overview

Each Python script runs as a separate OS process spawned by the Java plugin. Communication happens over stdin/stdout using a binary protocol. The Python process's stderr is inherited by the Java process, so print() output appears in the server console.

┌──────────────────────┐   stdin (Java→Python)     ┌─────────────────────┐
│                      │ ◄──────────────────────── │                     │
│    Python Process    │                           │    Java Plugin      │
│                      │ ────────────────────────► │                     │
│  - asyncio event     │   stdout (Python→Java)    │  - Bridge thread    │
│    loop (main)       │                           │    (per script)     │
│  - reader thread     │   stderr (Python→Java)    │  - Main thread      │
│    (daemon)          │ ────────────────────────► │    (server tick)    │
│                      │   (console output)        │                     │
└──────────────────────┘                           └─────────────────────┘

Per-script threads on Java side:

Per-script threads on Python side:


Wire Protocol

All messages use length-prefixed JSON over stdin/stdout:

┌─────────────┬─────────────────────────────┐
│  4 bytes    │  N bytes                    │
│  uint32 BE  │  UTF-8 JSON payload         │
│  (length N) │                             │
└─────────────┴─────────────────────────────┘

Example wire message

Calling player.getName() where the player object has handle 42:

{"type": "call", "id": 1, "method": "getName", "args_list": [], "handle": 42}

Response:

{"type": "return", "id": 1, "result": "Steve"}

Send implementation (Python)

def send(self, message):
    data = _json_dumps(message)          # orjson or json
    header = struct.pack("!I", len(data)) # 4-byte big-endian length
    with self._lock:
        self._stdout.write(header)
        self._stdout.write(data)
        self._stdout.flush()

The lock ensures header and payload are written atomically — without it, concurrent sends from different tasks could interleave.


Message Types

Python → Java (P2J)

Type Purpose Key Fields
call Invoke a method id, method, handle or target, args_list
call_batch Batch multiple calls atomic, messages[]
subscribe Register event listener event, priority, once_per_tick, throttle_ms
register_command Register a /command name, description, usage, permission
wait Wait N server ticks id, ticks
release Free object handles handles[]
ready Script finished loading
shutdown_ack Confirm shutdown received
event_done Event handler finished id
event_cancel Cancel the event id
event_result Return a value from event id, result, result_type

Java → Python (J2P)

Type Purpose Key Fields
return Successful call result id, result
error Call failed id, message, code
event Bukkit event fired event, payload, id
event_batch Multiple events at once event, payloads[]

Reader Thread

The Python-side reader thread is a daemon thread that blocks on stdin in a loop:

  1. Read 4-byte header → decode payload length
  2. Read N bytes of JSON payload → parse
  3. Sync responses (return/error matching a _pending_sync ID) → set threading.Event directly on the reader thread (fast path, no event loop involvement)
  4. Async responses → dispatch to event loop via call_soon_threadsafe()
  5. On disconnect → wake all pending waiters with BridgeError("Connection lost")

This two-tier design means sync property access (player.name) gets resolved on the reader thread without waiting for the event loop, while async calls integrate with asyncio naturally.


Further Reading