service - the object being exposed remotely. regular Python object, no base class needed.
protocol - shared RPC state for one endpoint. holds object registry, netref caches, boxer, handlers. one Protocol instance per endpoint.
connection - InvisiblesConnection. one sync connection per client. reentrant serving pattern. handles ALL protocol operations and method calls (both sync and async).
proxy (netref) - client-side transparent handle. matches the remote object's API exactly. sync methods are def, async methods are async def (using run_in_executor). created automatically during unboxing.
dispatcher - how the server runs method calls:
InlineDispatcher(default) - serialized, runs inline in serve threadThreadedDispatcher- thread pool for thread-safe objectsSharedDispatcher- serialized across all connections (shared lock)AsyncDispatcher- event loop thread for async methods, sync methods run inline
boxing - how objects cross the wire. the core dichotomy is lifecycle:
- objects WITHOUT lifecycle (primitives: int, str, float, bool, bytes, None, complex) go by-value via codec
- objects WITH lifecycle (everything else: lists, dicts, sets, custom objects) go by-reference - sender registers them, receiver gets a proxy
- tuples are protocol plumbing: recursively box each element (can mix values and references)
codec - serialization for primitive types. currently pickle. pluggable.
Endpoint (one per service)
|
+-- Protocol (shared state, ONE instance)
| +-- local_objects - registry of shared objects
| +-- netref_cache - weak cache of proxy instances
| +-- class_cache - netref class cache
| +-- boxer - boxing/unboxing (always sync)
| +-- handlers - request dispatch
|
+-- InvisiblesConnection - one per client
+-- all protocol ops: INSPECT, GETATTR, SETATTR, DIR, etc.
+-- all method calls: sync and async (same wire protocol)
+-- unboxing netrefs: HANDLE_INSPECT through same connection
+-- reentrant serving (nested requests while waiting)
+-- RLock for thread-safe concurrent serving (run_in_executor)
one connection handles everything. async is an execution model (server-side), not a communication protocol. the client sends requests through the sync channel regardless of whether the method is sync or async.
CLIENT SERVER
user code service object
| ^
v |
proxy (netref) dispatcher
| ^
| sync method --> InvisiblesConnection ----------> InvisiblesConnection
| box args -> send -> receive receive -> unbox -> dispatch
| reentrant wait <-- response <-- box result -> send
|
| async method -> run_in_executor(sync_request) -> same connection, same path
| event loop stays free server: AsyncDispatcher
| executor thread does reentrant awaits coroutine on its loop
| wait like sync returns result normally
|
v
result
- client calls
sync_request(HANDLE_GET_ROOT)on sync connection - server boxes the service as
REMOTE_REF, registers it inlocal_objects - client unboxes: sees
REMOTE_REF, callsHANDLE_INSPECTvia sync connection - client builds proxy class:
deffor sync methods,async deffor async methods - client creates proxy instance, caches it
HANDLE_INSPECT always goes through the sync connection. it's a protocol operation, not a user method call. the result (method signatures) is a tuple of primitives - boxes cleanly.
proxy.method(args)hits__getattribute__, finds method in class dict- method calls
sync_request(HANDLE_CALL_ATTR, proxy, name, args, kwargs)on sync connection - connection boxes args, sends request, enters reentrant serve loop
- server receives, unboxes, dispatches to method via dispatcher, boxes result, sends response
- client's serve loop receives response, unboxes, returns to caller
await proxy.method(args)hits__getattribute__, finds async method in class dict- async method uses
run_in_executor(sync_request)to avoid blocking event loop - executor thread calls
sync_request(HANDLE_CALL_ATTR, proxy, name, args, kwargs)- same as sync - server receives, unboxes, dispatches via AsyncDispatcher which awaits the coroutine on its event loop
- server boxes result, sends response - same as sync
- executor thread receives response, returns. future resolves, caller gets result
the core dichotomy is lifecycle. does the object have lifecycle or not?
-
primitives (int, str, float, bool, bytes, None, complex): no lifecycle, immutable. serialize and send by value. this is safe because there's nothing to get out of sync.
-
compound types (list, dict, set, custom objects): have lifecycle, can be mutated, can contain objects with lifecycle. send by reference. sender registers in local_objects, receiver creates a proxy. all operations go through RPC.
-
tuples: protocol plumbing. recursively box each element. a tuple can contain a mix of primitives (by value) and objects (by reference). tuples themselves are not serialized as a unit - each element is boxed independently.
-
exceptions: special case. must be real objects (not proxies) for gen.throw(), exit, isinstance to work. serialized by value.
-
netrefs returning to origin: when a proxy is passed back as an argument to its own server, the boxer recognizes it (same protocol) and sends LABEL_LOCAL_REF with the object_id. the server looks up the original object. zero serialization.
object to send
|
+-- tuple -> LABEL_TUPLE -> recursively box each element
+-- primitive (int, str, float, ...) -> LABEL_VALUE -> serialize via codec
+-- exception -> LABEL_VALUE -> serialize via codec
+-- netref from this connection -> LABEL_LOCAL_REF -> send object_id back
+-- anything else -> LABEL_REMOTE_REF -> register in local_objects, send object_id
receiver creates proxy
| scenario | connections | dispatcher |
|---|---|---|
| sync, single client | 1 | InlineDispatcher |
| sync, multiple clients, serialized | N | SharedDispatcher |
| sync, multiple clients, parallel | N | ThreadedDispatcher |
| async/mixed, single client | 1 | AsyncDispatcher |
| async/mixed, multiple clients | N | AsyncDispatcher |
one connection per client. always sync. dispatcher determines execution model.