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
- Java serializes an object → registers it in
ObjectRegistry→ gets handle ID - Wire sends:
{{"_handle": 42, "_type": "Player", "name": "Steve", "uuid": "...", ...}} - Python creates a
ProxyBase(or subclass) holding handle42and pre-populated fields - Python calls methods:
{{"method": "getName", "handle": 42}} - 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);
}}- Bidirectional mapping:
objects(handle → Java object) +reverseMap(Java object → handle) - Deduplication:
register()checksreverseMapfirst. If the same Java object is serialized again, the existing handle is returned — no duplicate registrations. - Identity equality: Uses
IdentityHashMap, notequals(). Two different Player objects with the same UUID get different handles. - Thread-safe reads:
ConcurrentHashMapallows lock-freeget()from any thread. - Handle 0: Reserved for
null. Never registered. - Counter:
AtomicIntegerstarting at 1, monotonically increasing.
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:
- Memory: Large objects (inventories, worlds) stay alive
- Stale entities: Dead/unloaded entities throw
EntityGoneExceptionon use - Fix: Remove references when done, or use weak references
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:
| Type | Fields |
|---|---|
| Player | name, uuid, location, world, gameMode, health, foodLevel |
| Entity | uuid, type, location, world, is_projectile, shooter/owner |
| Location | x, y, z, yaw, pitch, world |
| Block | x, y, z, location, type, world, inventory (if container) |
| ItemStack | type, amount, meta (name, lore, customModelData, attributes, NBT) |
| Inventory | size, contents[], holder, title |
| PotionEffect | type, duration, amplifier, ambient, particles, icon |
| Chunk | x, z, world |
Special type wrappers
Some types use wrapper objects instead of handles:
- UUID:
{{"uuid": "550e8400-e29b-41d4-a716-446655440000"}} - Enums:
{{"enum": "org.bukkit.Material", "name": "DIAMOND"}} - References:
{{"ref": "player", "id": "uuid-string"}}— for lazy resolution without a handle
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
- Location:
{{x, y, z, yaw, pitch, world}}→new Location(...) - Vector:
{{x, y, z}}→new Vector(...) - ItemStack: Full NBT/meta reconstruction
Argument type coercion
The reflective fallback (invokeReflective) automatically converts arguments to match Java method signatures:
- Python
int→ Javaint,long,Integer,Long - Python
float→ Javafloat,double,Float,Double - Python
str→ JavaString,Material(enum lookup),Sound(enum lookup) - Python
list→ Java arrays orList<> - Python
dict→ JavaMap<>
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 Type | Python Class |
|---|---|
Player | Player |
Entity | Entity |
World | World |
Location | Location |
Block | Block |
Chunk | Chunk |
ItemStack | Item |
Inventory | Inventory |
BossBar | BossBar |
| (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)
- Check
self.fieldsfirst — if the field was serialized, return it immediately (no RPC) - Otherwise, return a
BridgeMethodwrapper 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:
| Setter | Invalidated Fields |
|---|---|
teleport | location, world |
give_exp | exp, level |
set_game_mode | gameMode, game_mode |
set_health | health |
set_food_level | foodLevel, food_level |
level setter | level |
exp setter | exp |
max_health setter | health |
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:
getattr("get_health")→ returnsBridgeMethod(proxy, "get_health")BridgeMethod.call()→_connection.call(method="getHealth", handle=42, args_list=[])call()returnsBridgeCallwrappingasyncio.Futureawaitresolves 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.