Object handles, type serialization, and proxy classes

Serialization

How Java objects are serialized for the wire (as msgpack or JSON), how Python reconstructs them as proxy objects, and how the object handle registry works.


Object Handles

Java objects that cross the bridge are tracked with integer handles in an ObjectRegistry.

Registration flow

  1. Java serializes an object → registers it in ObjectRegistry → gets handle ID
  2. Wire sends: {{"_handle": 42, "_type": "Player", "name": "Steve", "uuid": "...", ...}}
  3. Python creates a ProxyBase (or subclass) holding handle 42 and pre-populated fields
  4. Python calls methods: {{"method": "getName", "handle": 42}}
  5. Java looks up handle 42 → gets the Player object → invokes method

ObjectRegistry internals

public class ObjectRegistry {{
    private final Map<Integer, Object> objects = new ConcurrentHashMap<>();
    private final IdentityHashMap<Object, Integer> reverseMap = new IdentityHashMap<>();
    private final AtomicInteger counter = new AtomicInteger(1);
}}

Release

Python proxies queue handle releases when garbage collected:

def __del__(self):
    if self._handle is not None and _connection is not None:
        _connection._queue_release(self._handle)

Releases are batched — up to 64 handles accumulate before a flush:

{{"type": "release", "handles": [42, 43, 44, 45]}}

Handle releases are always thread-safe and execute on the bridge thread (no main thread involvement).

Handle leaks

If Python holds references to proxy objects indefinitely (e.g. in a global list), the Java objects remain in the registry:


Java → Python Serialization

The BridgeSerializer converts Java objects into structured data (serialized as msgpack or JSON depending on the negotiated wire format) with type-specific field extraction.

Type-specific fields

Each Bukkit type gets custom serialization:

TypeFields
Playername, uuid, location, world, gameMode, health, foodLevel
Entityuuid, type, location, world, is_projectile, shooter/owner
Locationx, y, z, yaw, pitch, world
Blockx, y, z, location, type, world, inventory (if container)
ItemStacktype, amount, meta (name, lore, customModelData, attributes, NBT)
Inventorysize, contents[], holder, title
PotionEffecttype, duration, amplifier, ambient, particles, icon
Chunkx, z, world

Special type wrappers

Some types use wrapper objects instead of handles:

Circular reference handling

If an object is encountered during serialization that's already being serialized (circular reference), it gets registered in the ObjectRegistry and replaced with just a {{"handle": N}} to break the cycle.

Projectile attribution

For projectile entities, the serializer tries multiple methods to find the shooter/owner:

getShooter() → getOwner() → getOwningPlayer() → getOwningEntity() → getSummoner()

Both the entity reference and cached name/UUID fields are included, so the attribution works even if the shooter entity is no longer loaded.


Python → Java Deserialization

Java deserializes incoming arguments through the BridgeSerializer:

Handle resolution

{{"handle": 42}}ObjectRegistry.get(42) → Java object

Reference resolution

{{"ref": "player", "id": "uuid"}}Bukkit.getPlayer(UUID) (live lookup) {{"ref": "world", "id": "world_name"}}Bukkit.getWorld(name) {{"ref": "block", "id": "world:x:y:z"}} → parsed and resolved

Value object reconstruction

Argument type coercion

The reflective fallback (invokeReflective) automatically converts arguments to match Java method signatures:


Python-Side Deserialization

The BridgeConnection._deserialize() method reconstructs Python objects from deserialized wire data (msgpack dicts/lists or JSON):

def _deserialize(self, value):
    if isinstance(value, dict):
        if "__handle__" in value:
            return _proxy_from(value)      # ProxyBase or subclass
        if "__uuid__" in value:
            return uuid.UUID(value["__uuid__"])
        if "__enum__" in value:
            return _enum_from(...)         # EnumValue wrapper
        if {{"x", "y", "z"}}.issubset(value):
            return SimpleNamespace(...)     # Location/Vector
        return {{k: deserialize(v) for ...}} # Generic dict
    if isinstance(value, list):
        return [deserialize(v) for v in value]
    return value                           # Primitives pass through

Proxy class mapping

The _proxy_from() function maps Java type names to Python proxy classes:

Java TypePython Class
PlayerPlayer
EntityEntity
WorldWorld
LocationLocation
BlockBlock
ChunkChunk
ItemStackItem
InventoryInventory
BossBarBossBar
(unknown)ProxyBase

Each proxy has pre-populated fields from the serialized data. Accessing a known field (like player.name) returns the cached value instantly — no round trip.


ProxyBase Internals

ProxyBase is the Python wrapper for remote Java objects:

class ProxyBase:
    def __init__(self, handle, type_name, fields, target, ...):
        self._handle = handle        # Java ObjectRegistry ID
        self._type_name = type_name  # "Player", "Entity", etc.
        self.fields = fields or {{}}   # Pre-deserialized field cache
        self._target = target        # "server", "ref", etc.

Attribute access (getattr)

  1. Check self.fields first — if the field was serialized, return it immediately (no RPC)
  2. Otherwise, return a BridgeMethod wrapper that will dispatch an RPC when called
player.name         # → fields["name"] (cached, instant)
player.get_health() # → BridgeMethod → RPC call (round trip)

Fire-and-forget calls (_call_ff)

Void methods and setters use _call_ff() to skip waiting for a response:

def _call_ff(self, method, *args, **kwargs):
    """Invoke a bridge method as fire-and-forget (no response expected)."""
    _connection.call_fire_forget(method=method, args=list(args), handle=self._handle, ...)

The call message includes "no_response": true. Java executes the method but skips serializing and sending a result. This eliminates round-trip latency for ~80+ Entity and Player methods like teleport, send_message, set_health, etc.

Field cache invalidation (_invalidate_field)

Setters that modify fields cached in self.fields must invalidate them to prevent stale reads:

def _invalidate_field(self, *field_names):
    """Remove cached field values so next access fetches fresh data from Java."""
    for name in field_names:
        self.fields.pop(name, None)

Invalidation happens before the fire-and-forget call is sent. This guarantees no desync: the cached value is removed immediately, and the next read will fetch fresh data from Java (by which time the setter will have been processed).

Fields with invalidation:

SetterInvalidated Fields
teleportlocation, world
give_expexp, level
set_game_modegameMode, game_mode
set_healthhealth
set_food_levelfoodLevel, food_level
level setterlevel
exp setterexp
max_health setterhealth

Attribute setting (setattr)

Private attributes (_handle, _target, etc.) and fields are set locally. Public attributes dispatch set_attr RPC:

player.custom_tag = "vip"  # → {{"method": "set_attr", "handle": 42, "field": "custom_tag", "value": "vip"}}

Method dispatch

When you call a method on a proxy:

result = await player.get_health()

This goes through:

  1. getattr("get_health") → returns BridgeMethod(proxy, "get_health")
  2. BridgeMethod.call()_connection.call(method="getHealth", handle=42, args_list=[])
  3. call() returns BridgeCall wrapping asyncio.Future
  4. await resolves when Java responds

Name conversion: Python get_health → Java getHealth. Snake_case is automatically converted to camelCase.

Garbage collection

When a proxy is garbage collected, del queues the handle for release:

def __del__(self):
    if self._handle is not None:
        _connection._queue_release(self._handle)

This is why short-lived proxies don't leak handles — Python's GC eventually cleans them up.