Concepts¶
Client Interfaces and Resource Ownership¶
The synchronous Connection wraps the same asynchronous
Protocol used by async applications. Tool serialization,
packet framing, and remote execution are shared. The remote bootstrap does not
include the synchronous client or its background runtime.
flowchart LR
S["Synchronous caller threads"] --> B["Connection: private background loop"]
A["Async application loop"] --> P["Protocol"]
B --> P
P <-->|"Existing packet stream"| R["Remote Protocol and Tool methods"]
Resource |
Synchronous |
Async |
|---|---|---|
Local event loop |
Private loop in one background thread |
Application’s running loop |
Subprocess |
Owned by the connection |
|
Handshake |
Completed before a factory returns |
Completed when entering the protocol context |
Cleanup |
|
|
One synchronous connection owns one subprocess, protocol, and background thread. There is no global or shared runtime. Multiple external threads can call one open connection; results are matched by request ID. Do not close it until its callers finish unless you intend to interrupt their pending requests.
The synchronous factories clean up failed or interrupted startup. Context exit
closes the connection on success, operation failure, or KeyboardInterrupt.
Explicit close() rejects new calls, closes the channel, reaps its process, and
joins the thread. Repeated close is safe. Nested context entry and operations
after closing raise RuntimeError.
See Writing Tools for both client styles and Synchronous Connection for deadlines.
Both clients preserve a Tool method’s argument and result types. For async
generator methods, use Protocol: its call and stream() return an async
generator with the method’s item type. Connection rejects these methods with
TypeError before sending an RPC.
Cancellation and Logging¶
Synchronous RPC deadlines include first Tool upload, packet transmission, and
response waiting. Timeout raises TimeoutError; KeyboardInterrupt propagates.
Both cancel local waiting and remove the pending request. Async callers can use
asyncio.timeout around an operation to request the same local cancellation.
Cancelling an ordinary RPC stops local waiting without sending a remote cancellation packet. Remote work and its side effects can continue. Late responses for cancelled or unknown request IDs are ignored.
Streaming calls have a separate stop notification. Closing the local iterator
with aclose() or contextlib.aclosing sends it while the connection remains
open. The sender stops at its next permission check and closes the remote
generator then. The notification does not interrupt an await inside the
generator; a method waiting for data may need a separate resource-close call.
A bare break does not close an async iterator immediately. See the
streaming lifecycle for examples.
Cancellation while sending a packet can leave an incomplete frame and close the channel. A transport failure requires closing the connection and creating another; automatic reconnect is not available.
The close deadline limits graceful process shutdown. Cleanup then uses terminate, a one-second grace period, and kill. Total cleanup can take longer because local cancellation and executor shutdown require cooperative code.
Remote log records invoke local logging handlers in a delivery thread of their
own, not in the loop thread. A handler can therefore take as long as it needs
without delaying the calls of the connection. Keep handlers thread safe. A
handler must not call synchronous methods of that connection, because the close
waits for the records that wait. The queue is bounded: when handlers cannot keep
up, the records that do not fit are counted and the loss is reported through
the rmote.remote logger. A record that the level of its local logger excludes
is dropped where the packet arrives, so it never reaches that thread. The loop
remains active between RPCs, so background events can arrive while the caller
is idle.
The records that a call writes travel inside the response of that call, because that packet leaves anyway. A record of a call then costs no packet of its own. A record that appears between calls, or one of a call that runs long, travels in a packet of its own within a millisecond. Records of one logger arrive in the order they were written, whichever way they travelled. See what a record costs.
Use Protocol in async applications. Alternatively, use asyncio.to_thread
for the complete synchronous lifecycle. A direct synchronous call blocks the
calling loop. Cancelling a to_thread await does not stop its worker thread.
Bootstrap Flow¶
The chosen transport connects the client to a Python
interpreter’s stdin/stdout stream. For example, an SSH connection starts
ssh -T host python3 -qui; Docker/Kubernetes exec and local subprocesses carry
the same bootstrap (Python’s -qui flags run quietly, unbuffered, and interactively
to execute bootstrap statements as they arrive). The target needs a compatible Python interpreter;
installing rmote or copying the tool project there is not required.
When from_subprocess() is called, rmote creates
the rmote package and injects rmote.protocol into the remote Python
interpreter through stdin as a gzip-compressed, base64-encoded payload. Tool
and template modules arrive later, when needed:
exec(decompress(b64decode("..."))) # creates rmote and loads rmote.protocol
asyncio.run(_protocol.run()) # starts the remote event loop
The remote process then writes PROTOCOL READY\n to stdout; the local side waits for this
boundary before starting the packet exchange.
sequenceDiagram
participant Local
participant Remote
Local->>Remote: Bootstrap (exec compressed payload)
Remote-->>Local: PROTOCOL READY
Local->>Remote: SYNC Tool (tool_to_dict)
Remote-->>Local: ACK
Local->>Remote: RPC call
Remote-->>Local: Result
Wire Protocol¶
Frames and logical packets¶
A logical packet carries one request, response, log record or stream-control
message. A response that carries the log records of its call sets LOG as
well, and the records are then delivered before the result. It travels as one or more frames. Each frame has a fixed-size 21-byte
header (struct ">5sIIQ") followed by that frame’s payload bytes:
packet-beta
0-39: "magic - b'RMOTE' (5 bytes)"
40-71: "flags - Flags IntFlag (4 bytes)"
72-103: "length - frame payload bytes uint32 (4 bytes)"
104-167: "packet_id - request correlator uint64 (8 bytes)"
The sender pickles the object first. When new module dependencies are needed,
it wraps the source manifest and those object-pickle bytes in an outer pickle
and sets MODULES.
The resulting bytes are split into frames of at most FRAGMENT_SIZE (64 KiB
by default). All frames repeat the logical packet’s flags and packet_id;
FRAGMENT is added to every frame except the last. A frame may contain only
part of a pickle and cannot be decoded on its own.
Each frame body is then compressed on its own, against the history of the
connection: one zlib dictionary serves each direction, and every compressed
body ends with a sync flush. A body that repeats the words of an earlier one
therefore carries a reference instead of the words, which is what makes a
small packet cheap. A body that does not shrink travels raw and leaves the
dictionary untouched, so random or already compressed data costs one trial.
COMPRESSED marks the bodies that went through the dictionary, and the
receiver inflates exactly those, in the order they arrive.
The receiver reassembles the inflated frames by packet_id, installs any
source manifest, then unpickles the object. Frames from different packet IDs
can interleave because the sender releases the write lock between frames.
Frames for the same ID must be sent in order by one task. See
Protocol and Tool Support for the codec, reassembly limits and streaming budgets.
Bootstrap and the ready boundary travel plain, and the bootstrap carries the current protocol implementation to the remote interpreter. Both sides therefore run the same codec. Independently started peers must agree on it: a frame dictionary cannot be read by a peer that expects a gzip packet.
Flags¶
The flags field is a combination of Flags values:
Flag |
Value |
Meaning |
|---|---|---|
|
1 |
This frame body went through the zlib dictionary of its direction |
|
2 |
Request or stream-control direction |
|
4 |
This is a response to a request |
|
8 |
Tool synchronization |
|
16 |
Remote procedure call |
|
32 |
Response carries an exception |
|
64 |
Log records forwarded from remote; with |
|
128 |
More frames of this packet follow |
|
256 |
Stream item or permission control |
|
512 |
Source manifest before object pickle |
|
1024 |
Payload holds several items of one stream, each behind its uint32 length |
An ordinary SYNC | REQUEST or RPC | REQUEST expects a response. Streaming
methods start with the same RPC | REQUEST; their items arrive as
RPC | RESPONSE | STREAM | BATCH, where one packet carries the items the
sender had ready, followed by an ordinary response that ends the stream. An
exception response reports a failure after preceding items.
The consumer returns permission with RPC | STREAM | REQUEST on the stream’s
original packet ID. Its payload is (serialized_bytes, item_count) to return
consumed credit, or None to stop the sender. These control messages do not
receive acknowledgements. REQUEST and RESPONSE distinguish the producing
and consuming sides of a stream.
Minimal bootstrap and lazy dependencies¶
Bootstrap creates the rmote package and loads rmote.protocol, using only
Python’s standard library. Template engines, filters and tools are transferred
on demand. Import Template from rmote.templates; neither the root package
nor rmote.protocol re-exports it.
Tool definitions carry their declared module dependencies. Object arguments and
results can carry an outer source manifest with the MODULES flag. The receiver
registers these sources before unpickling the enclosed object bytes, allowing
Python to import their classes normally. This also handles templates nested in
containers, custom filter reducers and streaming results. Successful transfers
are remembered per connection. There is no missing-module round trip during
unpickling and no topological execution schedule.
Tool Serialization Lifecycle¶
For example, await remote(Disk.free_bytes, "/") sends the code needed to
define Disk before requesting free_bytes. The method body runs in the
remote interpreter; the local caller receives its return value or exception.
Tools are transferred lazily and cached for the lifetime of the connection:
ToolMetarecords class source for inline tools. File-based classes use the same cachedModuleBundleas module functions, preserving their imports.The first call sends the bundle in a
SYNC | REQUESTpacket. The receiver registers its sources withModuleLoaderand imports the defining module. Inline classes execute their class source in a separate namespace. The sources of a package travel once for a connection. Every class of the package shares one bundle, so a class announced later carries only the modules the peer still lacks, which is usually none of them.The receiver constructs a tool instance and stores it in
Protocol.tools. File-based class tools usemodule.ClassNameas their key. Inline tools use the bare class name, so two inline tools with the same name can collide.Concurrent first calls share loading locks. Classes of one package wait for the same lock, so its bundle is built and sent once instead of once for every class. Classes from one module reuse that module’s namespace. Later calls send an RPC packet without another successful upload.
Module functions use the same source bundles and loader. Package bundles
include nested Python modules; the in-memory import loader registers all
sources before importing the defining module. The module itself is stored under
its qualified name in Protocol.tools. Python resolves import order, relative
imports and cycles. An explicit __tool_package__ boundary can include sibling
modules; dependencies outside it must already be available on the target.
The source is compiled and executed in memory. rmote does not install a package
or write the tool’s .py file into the remote filesystem. The transferred local
filename identifies the code in tracebacks; it is not a deployed file path.
The subsequent RPC contains the method name, arguments and keyword arguments;
arguments, return values and exceptions are serialized with pickle.
Opening a connection does not upload tools. Each connection tracks its own loaded tools and uploads them on first use. Module/package source bundles use a process-wide LRU cache shared by connections. Restart the controller after source changes; loaded remote modules are immutable. See Writing Tools for source layout, dependencies, and custom return types.
Standard Streams and Trust¶
The remote bootstrap duplicates the descriptors used for protocol traffic and
redirects standard input, output, and error to /dev/null. The protocol copies
are not inherited by child commands. Remote print() and uncaptured command
output are discarded; use return values or logging to send information back.
Tool source executes on the target, and messages contain pickled Python values. Use rmote only with trusted peers and code. The packet format does not provide authentication or encryption; these come from the chosen transport.
Concurrency Model¶
rmote matches concurrent requests with their responses by packet ID:
Each ordinary RPC generates a
packet_idfrom a thread-safe counter. A streaming call allocates its ID when the caller starts reading the iterator.Ordinary calls store an
asyncio.FutureinProtocol.futures; streaming calls store a bounded queue inProtocol.streams, keyed bypacket_id.The
_looptask continuously reads incoming packets. Ordinary responses resolve the matching future; stream items and completion responses go to the matching queue. Permission messages update the sender’s stream budget.Remote-side handlers run as asyncio tasks. A
defTool method runs in a worker thread; anasync defmethod runs in the remote loop. Blocking work inside an async method blocks that loop. See Writing Tools for method selection.