Protocol and Tool Support¶
Asynchronous connections¶
Use Protocol from an asynchronous application. Enter its async context
before calling tools. See Quickstart for a complete connection example
and Concepts for subprocess ownership and cancellation.
The transport guide explains command prefixes, local
Python and prepared streams such as nc, including when to use
from_command or from_subprocess.
- class rmote.protocol.Protocol(reader, writer, *, decode_threshold=32768)[source]¶
Bases:
BaseProtocol- __call__(tool, /, *args, **kwargs)[source]¶
- Overloads:
self, tool (Callable[P, AsyncIterator[R]]), args (P.args), kwargs (P.kwargs) → AsyncGenerator[R, None]
self, tool (Callable[P, Coroutine[Any, Any, R]]), args (P.args), kwargs (P.kwargs) → Coroutine[Any, Any, R]
self, tool (Callable[P, R | Coroutine[Any, Any, R]]), args (P.args), kwargs (P.kwargs) → Coroutine[Any, Any, R]
Call a tool method on the peer.
An ordinary method gives a coroutine, so the caller awaits it. A method written as an async generator gives an async generator instead, so the caller reads it with
async forand the items arrive as the remote side produces them. A barebreakdoes not close that generator at once, so usecontextlib.aclosingwhen the close has to be immediate.A close stops the remote method at its next permission check, which happens when the method produces its next item. A method that waits for an item it never receives keeps running, so give such a method a separate operation that releases its resource.
- handle_stream_credit(returned, packet_id)[source]¶
Take the permission that a streaming consumer gave back.
A frame with STREAM and REQUEST always belongs to a stream this side produces, and one with STREAM and RESPONSE to a stream this side consumes. The direction tells the two registries apart, so the same number on both sides is never confused.
None means the consumer left, so the sender stops.
- Return type:
- handle_stream_item(flags, packet_id, payload, size)[source]¶
Hand one item of a streaming response to its consumer.
This stays synchronous, because it runs in the receive loop. The budget that the consumer gives the sender already bounds the backlog, so the limits here only catch a peer that ignores the budget.
- Return type:
- async prepare_call(tool)[source]¶
Make sure the tool is on the peer. Return the method id to call.
- Raises:
ValueError – tool is neither a Tool method nor an importable module function.
- Return type:
- async send_stream(items, packet_id)[source]¶
Send the items of a streaming method, several of them in one packet.
A separate task writes what gathered, so the items that the producer makes while a packet is on the wire travel in the next one. Each item needs permission from the consumer before it reaches that task, so a consumer that reads slowly slows the producer and one that leaves stops it. The permission is awaited before the write lock is taken, so a waiting stream never holds the transport.
- Raises:
ValueError – One item is above MAX_STREAM_ITEM. The consumer gets the failure, because an item that large could never find room.
- Return type:
- async stream(tool, /, *args, **kwargs)[source]¶
Call a streaming tool method and yield its items as they arrive.
The method must be an async generator on the remote side. The items travel as responses, so the peer pushes without a request per item, and the items it has ready travel together in one packet. A large packet is fragmented, so a long stream does not hold the channel and ordinary calls keep their progress.
Closing the iterator tells the sender to stop. The task that produces the items is cancelled, so a generator that waits for data it never receives is interrupted as well, and the cleanup of the method runs in either case. Use contextlib.aclosing for a deterministic close on an early break.
Synchronous code inside the method cannot be interrupted: the cancellation arrives at the next await. A method that holds a resource between calls still needs its own operation to release it, exactly as an ordinary method does.
The items that the sender has ready travel in one packet, so a write on one side and a read on the other serve a burst instead of serving every item. One connection therefore carries many streams at the same price: with 64-byte items the cost is near 2 µs per item from one stream to thirty-two. An item that is alone travels at once, so a producer that waits between items keeps the latency of each one.
Equal streams do not finish together: the last of thirty-two finishes about 40 percent of the run after the first, because a stream that is served sends a whole batch. Where every stream must advance evenly, give each one its own connection.
- Parameters:
tool (
Callable[[ParamSpec(P)],AsyncIterator[TypeVar(R)]]) – Method of a Tool class, defined as an async generator.*args (
ParamSpecArgs) – Positional arguments of the method.**kwargs (
ParamSpecKwargs) – Keyword arguments of the method.
- Yields:
Items in the order the remote side produced them.
- Raises:
ConnectionError – The connection dropped while the stream was open.
RuntimeError – The peer exceeded the stream budget or item limit.
Exception – Whatever the remote method raised.
- uncompressed(tool, /, *args, **kwargs)[source]¶
- Overloads:
self, tool (Callable[P, AsyncIterator[R]]), args (P.args), kwargs (P.kwargs) → AsyncGenerator[R, None]
self, tool (Callable[P, Coroutine[Any, Any, R]]), args (P.args), kwargs (P.kwargs) → Coroutine[Any, Any, R]
self, tool (Callable[P, R | Coroutine[Any, Any, R]]), args (P.args), kwargs (P.kwargs) → Coroutine[Any, Any, R]
Call without compressing the request, response or stream items.
All arguments belong to the tool, including a keyword named compressed. Concurrent calls keep their own compression policy. Stream consumers must close the returned generator just as for an ordinary stream call.
Tool definitions and commands¶
Subclass Tool in a readable Python module. Import async_process from
rmote.process and use await async_process(...) inside async tool methods to
run commands without blocking other calls; set capture_output=True when the return value needs output.
Built-in command tools use async methods, so direct local calls require
await. Calls through Protocol and the synchronous Connection keep their
normal calling conventions. The synchronous process helper remains available.
See Subprocess helpers for subprocess helpers and Writing Tools for executable examples.
- rmote.protocol.inline(method)[source]¶
Run a synchronous Tool method directly on the event loop, without a worker.
A synchronous method runs in a worker thread, because a call that waits would stop the loop and with it every other call of the connection. The hand-off to that thread costs about 30 us, which is more than a method that only reads a field, formats a value or returns a constant.
Mark such a method with this decorator, above or below
staticmethod. Use it only when the method cannot wait: no file, no socket, no lock held by code that waits, and no long computation. The decorator does not enforce this requirement or change direct local calls.>>> class Tiny(Tool): ... @staticmethod ... @inline ... def answer() -> int: ... return 42
- Return type:
TypeVar(Method)
Streaming calls¶
A tool method written as an async generator streams its result. The items
travel as responses with the STREAM flag set, and the ordinary response that
follows them ends the stream. The remote side pushes the items as it produces
them and does not wait for a request per item. The items that it has ready
travel together in one packet, which the BATCH flag marks.
The call is native. protocol(Tool.method, ...) gives a coroutine for an
ordinary method and an async iterator for an async generator:
async for item in protocol(Tool.items):
handle(item)
Annotate the method as returning AsyncIterator[T], or the type checker reads
the call as an ordinary one.
Frame fragmentation keeps a long stream from holding the channel, so ordinary calls keep their progress while a stream runs.
Flow control is a permission budget. The sender may have MAX_STREAM_WINDOW
serialized bytes and MAX_STREAM_BACKLOG items in flight, and the consumer
gives both back in batches when it asks for the next item. A slow consumer
therefore slows the sender instead of filling memory. The count limit goes with
the byte budget, so a long run of small items cannot pass it.
The budget counts the serialized size of an item before compression. That is not the memory of the Python object, which can be larger or smaller, but it is what the peer has to hold and what the wire carries.
An item above MAX_STREAM_ITEM is refused with a ValueError, because it could
never find room. An item that is merely larger than the whole budget still goes
out, alone, once nothing else is in flight.
The limits are enforced again on the receive side, as the last defence against a
peer that ignores the budget. Such a peer ends the stream with a RuntimeError.
How many streams one connection carries¶
The items that a sender has ready travel in one packet, so a write on one side
and a read on the other serve a burst instead of serving every item. The budget
of the stream bounds that packet, so one of them holds at most 256 items or
4 MiB. Measured on a local transport with 64-byte items, 16 384 of them in
total, by python -m benchmarks.streams, with both ways in alternating runs:
streams |
µs per item |
before |
reads per item |
before |
local CPU µs |
before |
|---|---|---|---|---|---|---|
1 |
3.01 |
7.26 |
0.00 |
0.01 |
0.61 |
6.71 |
4 |
2.03 |
6.82 |
0.00 |
0.00 |
0.61 |
6.71 |
8 |
2.08 |
16.31 |
0.00 |
0.69 |
0.61 |
12.21 |
16 |
2.11 |
15.63 |
0.00 |
0.63 |
0.61 |
11.60 |
32 |
2.06 |
16.26 |
0.00 |
0.71 |
0.61 |
12.21 |
The “before” columns are the measurement of one packet per item. A connection then carried a handful of streams at full speed and twice the cost beyond it, because no stream filled its window any more and every item paid a syscall on both sides. Nothing of that is left: the cost per item no longer depends on how many streams share the connection.
Equal streams still do not finish together. The last of thirty-two finishes about 40 percent of the run after the first, against 33 percent with one packet per item, because a stream that is served sends a whole batch. The run itself is eight times shorter, so the difference between the first and the last finish fell from 87 ms to 15 ms. A smaller batch makes the finishes closer and the run longer: a limit of eight items per packet gives a spread of 17 percent and 3.66 µs per item. Where every stream must advance evenly, give each one its own connection.
When a stream is the right shape¶
The same ten thousand small items, over one local connection, in three shapes:
shape |
total |
per item |
|---|---|---|
one call returning a list |
4.5 ms |
0.45 µs |
a stream |
91.1 ms |
9.11 µs |
one call per item |
1 994 ms |
199 µs |
A call per item is never the answer: a round trip costs about 200 µs whatever it carries. One call with a list is the cheapest shape while the result fits in memory on both sides and the caller needs all of it: it serializes and compresses once. A stream earns its keep when the result must not be held whole, when the items appear over time, or when the consumer has to see the first ones before the last ones exist. The gap narrows as items grow, because the per-item cost stops dominating.
What a stream does not break¶
These properties were measured and need no change.
An ordinary call keeps its progress while a stream of 1 MiB items runs, because every packet travels as 64 KiB frames and the write lock is released between them. Latency of a call that returns at once, 60 samples on a free channel and during such a stream:
channel |
minimum |
p50 |
p95 |
maximum |
|---|---|---|---|---|
free |
0.181 ms |
0.189 ms |
0.215 ms |
0.236 ms |
a stream of 1 MiB items |
0.184 ms |
0.203 ms |
0.310 ms |
0.449 ms |
A slow consumer costs the producer nothing beyond its own pace, and neither side spins while it waits. Two hundred items of 32 KiB with a pause per item:
pause per item |
wall |
the pauses alone |
local CPU |
remote CPU |
|---|---|---|---|---|
none |
0.006 s |
0 s |
0.010 s |
0.006 s |
1 ms |
0.256 s |
0.200 s |
0.000 s |
0.008 s |
5 ms |
1.211 s |
1.000 s |
0.040 s |
0.031 s |
The consumer returns permission in batches of half the budget, which a sweep of batch sizes showed to be on a plateau: returning it per item costs twice as much per item, a batch of 64 is within noise of the current one, and a batch of 255 loses 11 percent because it nearly equals the window of 256 items, so the sender runs out and waits. That sweep ran under load from other work on the machine, so its ratios are the reliable part. Do not change the rule.
To leave the loop tells the sender to stop, so an abandoned stream frees the
peer. A bare break does not close the iterator at once, so use
contextlib.aclosing when the close has to be immediate:
async with contextlib.aclosing(protocol(Tool.items)) as items:
async for item in items:
if enough(item):
break
Closing the iterator sends a stop notification. That notification releases the
sender’s permission and cancels the task that produces the items. The remote
generator therefore receives the cancellation wherever it waits, its finally
runs, and the task leaves the peer. A generator that waits for data it never
receives ends the same way as one that keeps producing.
Two limits remain. Synchronous code inside the method cannot be interrupted:
the cancellation arrives at the next await, so a method that blocks a thread
keeps blocking it. And a resource that the method holds between calls needs its
own call to release it, exactly as with an ordinary call:
key = await protocol(Vty.open, "/bin/sh")
async with contextlib.aclosing(protocol(Vty.output, key)) as output:
async for chunk in output:
if enough(chunk):
break
# The session waits for more output, so a separate call releases it.
await protocol(Vty.close, key)
What the wire layer costs¶
Measured on a local transport with both sides on one quiet machine, best of 9,
by python -m benchmarks.wire. The round trip of a call that returns its own
argument, with the part this side spends before the bytes leave:
payload |
round trip |
serialize |
send |
everything else |
|---|---|---|---|---|
8 B |
167 µs |
3.0 µs |
3.1 µs |
161 µs |
100 B |
159 µs |
2.3 |
2.6 |
154 |
1 KiB |
208 µs |
3.6 |
13.9 |
190 |
16 KiB |
244 µs |
3.6 |
24.7 |
216 |
256 KiB |
742 µs |
12.0 |
230 |
501 |
1 MiB |
2 167 µs |
38.5 |
803 |
1 326 |
The floor is about 160 µs, of which this side spends 6: the rest is two passes
through a pipe and the whole remote side, including the worker thread that a
synchronous Tool method runs in. A method marked with
rmote.protocol.inline() skips that thread.
One connection serves many calls at once, because a call waits for bytes rather than for the processor:
calls in flight |
per call |
calls per second |
|---|---|---|
1 |
233 µs |
4 300 |
8 |
69 µs |
14 400 |
64 |
42 µs |
23 900 |
Bytes per second for content that compresses and for content that does not. The sender refuses to compress dense data, so random bytes are faster than text:
payload |
content |
upload |
download |
|---|---|---|---|
1 MiB |
compressible |
845 MiB/s |
871 MiB/s |
1 MiB |
random |
1 150 MiB/s |
1 162 MiB/s |
8 MiB |
compressible |
1 001 MiB/s |
981 MiB/s |
8 MiB |
random |
1 385 MiB/s |
1 284 MiB/s |
Two parts of the wire layer were measured and are not worth tuning. The packet header costs 43 ns to pack and 33 ns to unpack, which is 0.06 percent of the round trip of a small call. Slicing a payload into frames costs 1.5 µs per frame, and the join on the other side costs less than that.
What a remote log record costs¶
A record that a Tool method writes reaches the local handlers of the caller. It
travels inside the response of that call, so it needs no packet of its own.
Measured by python -m benchmarks.logs on the same quiet machine, with one
record per call and a local handler that keeps nothing. Both ways share one
process and the rounds alternate, so the load of the machine hits them equally:
threads |
the record travels |
calls per second |
share of the run without records |
loop CPU per record |
records with a packet |
|---|---|---|---|---|---|
8 |
with the response |
11 982 |
0.80 |
9.1 µs |
4% |
8 |
in its own packet |
11 444 |
0.77 |
14.1 µs |
70% |
8 |
filtered on arrival |
12 873 |
0.86 |
4.8 µs |
3% |
32 |
with the response |
17 723 |
0.86 |
3.5 µs |
3% |
32 |
in its own packet |
17 666 |
0.86 |
4.0 µs |
19% |
32 |
filtered on arrival |
18 484 |
0.90 |
3.0 µs |
3% |
A record keeps a packet of its own when it cannot wait for the response: when another batch is on the wire, or when the batch is above the limits below.
The level of the local logger is tested where the packet arrives. A record that
no local handler accepts is dropped there, so it costs no hand-off to the
delivery thread and no logging.LogRecord: raise the level of
rmote.remote.<name> for the records you do not want, and they become almost
free. That price, 0.86 of the run at eight threads, is also what the wire alone
costs: with the delivery replaced by an empty function the share is the same.
A record a handler does want therefore costs 0.06 of the run more, which is the
hand-off and the work of the handlers; they run in the delivery thread and
compete with the loop thread for the interpreter.
A burst of more than RemoteLogHandler.ATTACH_RECORDS records, or one above
ATTACH_BYTES of text, keeps a packet of its own, so a response never becomes
a fragmented packet because of the records it carries. A record of a call that
runs long waits no more than ATTACH_DELAY for a response and then travels
alone. Records of one logger always arrive in the order they were written.
Frame fragmentation¶
A logical packet travels as one or more frames. Every frame repeats the logical
flags of the packet. The FRAGMENT flag marks each frame except the last one,
so the receiver knows that more data follows.
BaseProtocol.FRAGMENT_SIZE sets the largest payload in one frame. The sender
serializes the whole packet first, then writes the fragments and compresses each
body as it goes. It releases the write lock between frames. Packets with a
different packet_id therefore interleave on the wire, and one large transfer
cannot hold the channel for its whole duration. A frame that arrives above
FRAGMENT_SIZE, before or after inflation, is refused: no sender of this
protocol writes one.
The receiver keeps one buffer per packet_id in a rmote.protocol.FragmentBuffer.
A packet that completes first is returned first, in any fragment order. The
buffer enforces MAX_REASSEMBLY_BYTES and MAX_PARTIAL_PACKETS and raises
ValueError when a peer passes a limit.
Fragments of one packet_id must come from one task. The receiver joins them in
arrival order, so concurrent senders on the same id would mix their bytes.
Frame compression¶
Each frame body is compressed on its own, against the history of the
connection. One zlib dictionary serves each direction, at
BaseProtocol.COMPRESSION_LEVEL, 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: the
method name, the flags and the shape of a result are the same in every call.
COMPRESSED marks the bodies that went through the dictionary. The receiver
inflates exactly those, in the order they arrive, before the fragments of a
packet are joined. The bootstrap and the ready boundary travel plain, and the
boundary opens the codec of each direction.
Small incoming payloads decode on the event loop. Pickle decoding above
decode_threshold (32 KiB of decoded bytes by default) uses the default
executor. Pass this keyword to the Protocol constructor or its factories,
or to Connection.from_local, from_command or from_ssh. Factories apply
the value to both endpoints. Positive integers set the threshold; -1 keeps
all receive decoding on the event loop, including fragmented packets. Zero
and values below -1 are rejected. For example:
async with await Protocol.from_command("ssh", "-T", "host", decode_threshold=-1) as remote:
...
A single frame inflates inline, bounded by FRAGMENT_SIZE (64 KiB).
Once decoded fragments of a packet exceed the threshold, subsequent frames
decompress and decode off the loop in groups of at most
BaseProtocol.DECODE_FRAMES (16 frames), stopping at the first completed
packet. The read lock preserves dictionary order and waits for an active
decoder before propagating cancellation. Stream credits and reassembly
limits still bound buffering.
Packets carrying a MODULES source envelope install and unpickle on the event
loop. Later large packets can import registered modules and run reconstruction
hooks in an executor thread; these hooks must not require a running event loop.
The hand-off costs throughput for large batches in exchange for shorter loop
pauses. python -m benchmarks.receive measures both; use --burst 38 to replay
simultaneous bursts at the same average rate, and --rate 0 for peak throughput.
Compare --decode-threshold -1 with the default to measure the tradeoff.
A body whose first DENSITY_SAMPLE bytes use more than DENSITY_LIMIT byte
values travels raw and leaves the dictionary untouched, so random and already
compressed data costs one short sample. No trial compression runs, because a
copy of the dictionary costs more than the pass it would test: the deflate
state holds its whole window. A body that passes the sample and still does not
shrink therefore travels compressed and grows by the few bytes of its deflate
block.
A tool that knows its data cannot shrink says so, and the call carries that decision to the request, the response and the items of a stream:
value = await remote.uncompressed(Tool.method, *args, **kwargs)
async for item in remote.uncompressed(Tool.items):
handle(item)
FileSync decides from the name and the size of the file, so an archive or an
image is not compressed twice while text and JSON still are.
Measured on a local transport by python -m benchmarks.wire and a byte count
of the same calls, against the policy that compressed each packet on its own,
in alternating runs:
workload |
packet policy |
frame codec |
|---|---|---|
100 empty calls |
201–203 µs, 10 700 B |
210 µs, 3 000 B |
100 calls of 1 KiB JSON |
225–227 µs, 20 300 B |
210–213 µs, 4 177 B |
5 echoes of 1 MiB of text |
2.60–2.72 ms, 34 000 B |
2.62–2.64 ms, 37 938 B |
5 echoes of 1 MiB of random |
1.89–1.93 ms, 5 245 220 B |
1.91–1.95 ms, 5 244 800 B |
calls per second, 1 in flight |
3 956–4 107 |
3 864–3 922 |
calls per second, 8 in flight |
17 093–17 293 |
15 038–16 178 |
calls per second, 64 in flight |
30 985–31 260 |
27 047 |
stream items, 8 streams |
1.71–1.75 µs |
1.77 µs |
Small traffic costs three to five times fewer bytes, because the dictionary answers for what repeats. The price is the deflate pass of every frame: one call in flight loses 3 percent of the calls per second, eight lose 8 percent and sixty-four lose 13 percent. Bulk transfers and streams keep their speed, and bulk text costs 12 percent more bytes, because the codec flushes every frame while one gzip pass had the whole packet. Random data is unchanged: both policies refuse to compress it.
A local transport carries bytes for free, so these numbers are the cost without the gain. The gain appears where the link is slower than the processor; that case is not measured here.
A connection that needs the old policy sets FRAME_CODEC to False on its
protocol class. Both sides run the same source, so they always agree; a peer
that was started separately must use the same setting.
A compressed body that does not end at a flush boundary, inflates above
FRAGMENT_SIZE, or carries bytes after the end of its stream ends the
connection with a ValueError. The dictionary of that direction cannot be
repaired, because it no longer matches the sender.
Protocol internals¶
The clients use these helpers for bootstrap, source transfer, and packet handling. Application code normally calls tools through a connection.
- class rmote.protocol.BaseProtocol(reader, writer, *, decode_threshold=32768)[source]¶
Bases:
object- BOUNDARY = b'PROTOCOL READY\n'¶
- COMPRESSION_INLINE = 262144¶
- COMPRESSION_LEVEL = 3¶
- COMPRESSION_RATIO = 0.95¶
- COMPRESSION_SAMPLE = 8192¶
- COMPRESSION_THRESHOLD = 1024¶
- DECODE_FRAMES = 16¶
- DECODE_INLINE = 32768¶
- DECOMPRESSION_INLINE = 32768¶
- DENSITY_LIMIT = 128¶
- DENSITY_SAMPLE = 256¶
- FRAGMENT_SIZE = 65536¶
- ITEM_HEADER = Struct('>I')¶
- MAGIC = b'RMOTE'¶
- MAX_PARTIAL_PACKETS = 256¶
- MAX_REASSEMBLY_BYTES = 268435456¶
- MAX_START_FAILURE_LINES = 10¶
- MAX_START_FAILURE_TEXT = 2000¶
- MAX_STREAM_BACKLOG = 256¶
- MAX_STREAM_ITEM = 67108864¶
- MAX_STREAM_WINDOW = 4194304¶
- PACKET_HEADER = Struct('>5sIIQ')¶
- classmethod batch(frames)[source]¶
Join pickled items into one payload, each behind its length.
- Return type:
- compressed(payload)[source]¶
Compress payload when that pays. None keeps the original bytes.
Three tests, from the cheapest to the most expensive: the byte values of a short sample, a compression pass over a larger sample, and the result of the full pass. The last one is unconditional, so a packet never grows on the wire.
- decode_frames(frames)[source]¶
Inflate a bounded group in wire order and decode its final packet.
Only the last frame can complete a packet. A source envelope is handed back as bytes, so its installation and decoding remain on the loop. The caller holds read_lock until this worker finishes.
- async static decode_in_thread(function, *args)[source]¶
Run decoding off the loop without releasing its state on cancellation.
Cancelling an executor future cannot stop its function. Keep the caller’s read lock until it finishes, including repeated cancellation, so the next frame never races a worker that still advances the zlib dictionary.
- Return type:
- decode_packet(payload, flags, packet_id)[source]¶
Decode one reassembled packet; install source envelopes on the loop.
- Return type:
- dense(payload)[source]¶
True when a short sample uses too many byte values to shrink.
This is a guess, and a cheap one. It can refuse a payload that would have shrunk a little, which costs bytes but no time. It cannot make a packet grow, because the result of every pass is checked as well.
- Return type:
- encode(value)[source]¶
Pickle one value, and report the sources the peer does not have.
The caller decides where the sources travel. One value of its own carries them in the envelope of its packet; the items of a batch share one envelope for all of them.
- async classmethod from_command(*argv, python='python3', stderr=-1, env=None, start_new_session=False, tls=None, decode_threshold=32768)[source]¶
Bootstrap a Protocol over any command that starts a remote Python REPL.
The interpreter and its flags are appended to argv. For example,
from_command("ssh", "-T", "host")runsssh -T host python3 -qui. An empty argv starts the interpreter on the local host.- Parameters:
*argv (
Text) – Transport command and its arguments. It must pass stdin and stdout through without changing the bytes.python (
Text) – Python executable to start at the far end.stderr (
getint) – Where to redirect the remote stderr. The default pipe is read by this side: its last lines explain a failed start, and the rest is dropped. Pass DEVNULL to discard it, or a descriptor to let the caller see it live.env (
WSGIEnvironment[Text,Text] |None) – Environment for the transport command. None inherits it.start_new_session (
bool) – Isolate the transport from local terminal signals.tls (
TLSConfig|None) – Optional controller certificate and remote trust settings.decode_threshold (
getint) – Decoded packet bytes above which receive uses a worker, on both endpoints. Default 32 KiB; -1 disables workers.
- Return type:
Self- Returns:
A connected instance. The caller must still enter its context.
- async classmethod from_ssh(host, *, user=None, port=None, identity=None, python='python3', ssh_options=None, stderr=-1, tls=None, decode_threshold=32768)[source]¶
Bootstrap a Protocol over SSH.
- Parameters:
host (
Text) – Remote host, optionally inuser@hostform.user (
Text|None) – Remote username (-l). Overrides any user embedded in host.port (
getint|None) – SSH port (-p).python (
Text) – Python executable on the remote host.ssh_options (
list[Text] |None) – Extra arguments inserted before the host in thesshcommand (e.g.["-o", "StrictHostKeyChecking=no"]).stderr (
getint) – Where to redirect remote stderr. The default pipe is read by this side, so a refusal of ssh appears in the error of the start.
- Return type:
Self
- async classmethod from_stdio(logging_level=20, *, decode_threshold=32768)[source]¶
- Return type:
Self
- async classmethod from_subprocess(process, *, tls=None, decode_threshold=32768)[source]¶
- Return type:
Self
- async pack(payload)[source]¶
Compress off the loop only when the work pays for the hand-off.
The density check costs about a microsecond, so it runs here: a large payload that cannot shrink is refused without paying for a worker thread at all.
- async read_boundary()[source]¶
Wait for the ready boundary, keeping what the peer said before it.
A peer that cannot start writes its reason to this stream, and its interpreter can write a traceback here too. Those lines go into the error, so the caller learns the cause instead of only the symptom.
The boundary also opens the codec of this direction: everything before it travels plain, and every frame after it can carry a compressed body.
- Return type:
- async receive()[source]¶
Read frames until one logical packet is complete, then return it.
Frames of different packet_id can interleave. Fragments are buffered per packet_id, so a packet that completes first returns first.
Small payloads decode inline; large payloads use the default executor. Source envelopes install and unpickle on the event loop. Cancellation waits for an active worker before releasing the read lock, because its zlib dictionary and fragment buffers belong to this direction.
- Return type:
- static report_start_failure(stream, report, error)[source]¶
Report why this side cannot start, on both channels it still has.
One plain line goes to the protocol stream, before the ready boundary, so the peer reports a cause instead of an unexplained end of connection. The complete traceback goes to the original stderr, for whoever reads the transport. Raw descriptors are used, because the failure can be the channel setup itself.
This writes to the protocol stream only while the connection is being abandoned, so it cannot corrupt a packet. A failed report is ignored: the original failure is what matters.
- Return type:
- async send_frame(payload, flags, packet_id)[source]¶
Write one frame as a single unit. Hold the write lock for that frame.
The frame codec compresses the body here, because the dictionary of the direction must move in the order the bodies reach the wire. The work stays on the loop: the lock serializes it anyway, so a worker thread would only add its hand-off to the price.
- Return type:
- async send_payload(payload, flags, packet_id)[source]¶
Write one logical packet. Split it into fragments when it is large.
Every fragment carries the logical flags. The FRAGMENT flag marks all fragments except the last. The write lock is released between fragments, so packets with a different packet_id interleave instead of waiting.
One packet_id must be sent by one task only. The peer joins its fragments in arrival order, and concurrent senders would mix their bytes.
- Return type:
- async send_serialized(payload, flags, packet_id)[source]¶
Compress a serialized payload when that pays, then write it.
A streaming sender serializes the item itself, because it needs the size for its budget. This method takes that work and does not repeat it.
The frame codec compresses the body of each frame instead, so this method does nothing while that codec is installed.
- Return type:
- serialize(value, flags)[source]¶
Put source definitions outside the object pickle, before it is decoded.
- classmethod split(payload)[source]¶
Decode the items of one batch payload, in the order they were sent.
- Raises:
ValueError – The lengths do not describe the payload, which means a broken peer. The connection cannot continue after that.
- Return type:
- classmethod start_failure(said, transport=())[source]¶
Build the reason a peer did not reach the ready boundary.
Two streams carry evidence. The protocol stream holds what the peer said before the boundary. The stderr of the transport holds what the interpreter or the transport itself said, and that is the only evidence of a failure before our code runs: a missing module, a syntax error of an old interpreter, or ssh that refuses to connect. The tail of each one is kept, because the cause of a traceback is its last line.
- Return type:
- async start_tls(context, *, server_side, server_hostname=None, timeout=10.0)[source]¶
Establish TLS before exchanging any rmote boundaries or frames.
- Return type:
- async unpack(payload)[source]¶
Decompress, off the loop only when the work pays for the hand-off.
The decision uses the compressed size, so the limit stays low: the work is proportional to the result, which a high ratio makes much larger than the frame that carries it.
- Return type:
- watch_stderr(reader)[source]¶
Read the stderr of the transport for as long as it lasts.
The lines of the start go into the failure of the handshake. The reading continues after the start, because a transport that writes more would otherwise fill the pipe and block on its own write. Only the last lines are kept, so a chatty host costs no memory.
- Return type:
- class rmote.protocol.Flags(*values)[source]¶
Bases:
IntFlag- BATCH = 1024¶
- COMPRESSED = 1¶
- EXCEPTION = 32¶
- FRAGMENT = 128¶
- LOG = 64¶
- MODULES = 512¶
- REQUEST = 2¶
- RESPONSE = 4¶
- RPC = 16¶
- STREAM = 256¶
- SYNC = 8¶
- class rmote.protocol.FragmentBuffer(max_bytes, max_packets)[source]¶
Bases:
objectReassembly buffer for packets that arrive in more than one frame.
Frames of different packets can interleave, so each packet_id keeps its own buffer. The limits stop a faulty peer from exhausting memory.
- class rmote.protocol.Packet(payload, flags, packet_id, size)[source]¶
Bases:
objectOne complete packet that arrived from the peer.
size is the length of the serialized payload before compression. A streaming consumer needs it, to give the sender back the budget it spent.
- class rmote.protocol.StreamCredit(max_bytes, max_items, bytes_in_flight=0, items_in_flight=0, released=False, ready=<factory>)[source]¶
Bases:
objectPermission to send the items of one streaming response.
The budget is the serialized size of the items in flight, measured before compression. That is not the memory of the Python objects, which can be larger or smaller, but it is what the peer has to hold and what the wire carries.
A count limit goes with the budget, so a long run of small items cannot pass it. The consumer gives both back as it takes the items.
- fits(size)[source]¶
True when one more item of size bytes may go out now.
An item larger than the whole budget goes out alone, once nothing else is in flight. It therefore never waits for room that cannot appear.
- Return type:
- give_back(size, items)[source]¶
Take back the budget of items the consumer has taken.
- Raises:
ValueError – The peer returned more than it holds, or a negative amount. Both mean a broken peer, so the stream must not go on.
- Return type:
- ready: Event¶
- rmote.protocol.streaming_method(tool)[source]¶
True when tool is an async generator, also behind a descriptor.
A static method or a class method hides the function in __func__.
- Return type:
- rmote.protocol.tool_to_dict(cls, known=())[source]¶
Describe a file-based class by its module bundle, or an inline class by source.
known names the modules the peer already holds. Every class of a package shares one bundle, so its sources travel once for a connection and the classes that follow carry only what is still missing.
- rmote.protocol.tool_key(definition)[source]¶
Give the name a tool definition is registered under, on either side.
A definition that names a module keeps the module and the qualified name, so two modules can hold a class of the same name. An inline definition carries source and no module, so its own name is the key. Both sides take the key from the definition, and they cannot disagree about it.
- Return type:
- rmote.protocol.tool_from_dict(data, context=None)[source]¶
Restore file-based classes through normal imports; execute inline source separately.
- class rmote.protocol.ModuleBundle[source]¶
Bases:
objectBuild a source bundle for a module, or a package and its descendants.
A module can declare
__tool_package__ = 'my_package'to include that package instead. Imports outside this boundary must exist on the target. Only regular filesystem packages and Python source files are supported.- classmethod build(module, seen=None)[source]¶
Return the bundle of module, through the cache when it can.
A call without seen asks for a whole bundle, which depends only on the root and is therefore cached. A recursive call carries the modules its caller already has, so its result is partial and is not cached.
The caller receives a copy, because the cached mapping is read-only.
- Return type:
WSGIEnvironment[Text,ModuleSource]
- classmethod bundle(cls, module)[source]¶
Cache the whole bundle of a root; restart after source edits.
Treat the returned mapping and its source records as read-only.
- Return type:
MappingProxyType[Text,ModuleSource]
- classmethod collect(cls, root)[source]¶
Cache sources for the root resolved by build; restart after source edits.
Treat the returned mapping and its source records as read-only.
- Return type:
MappingProxyType[Text,ModuleSource]
- static declaring(module)[source]¶
Return module when it declares a package to transfer.
- Return type:
- classmethod expand(module, seen)[source]¶
Collect the sources of a root and of everything it references.
- Return type:
WSGIEnvironment[Text,ModuleSource]
- static owner_module(owner)[source]¶
Cache the declaring module of a class or a function.
The answer depends only on the owner, and a module does not gain the declaration while the process runs. The cache therefore holds one entry for each type that a payload carries, not one for each value.
- Return type:
- classmethod references(source, module, seen=None)[source]¶
Find explicitly transferable dependencies referenced by Tool source.
- class rmote.protocol.ModuleLoader[source]¶
Bases:
MetaPathFinder,LoaderImport transferred sources in memory using Python’s normal import locks.
Sources are immutable within a remote interpreter. Failed modules follow ordinary import semantics: they can be retried; successful dependencies stay loaded. Parent packages outside the bundle are empty namespaces if absent.
- create_module(spec)[source]¶
Return a module to initialize and into which to load.
This method should raise ImportError if anything prevents it from creating a new module. It may return None to indicate that the spec should create the new module.
- Return type:
- get_source(fullname)[source]¶
Expose in-memory source for tracebacks and inspect.getsource.
- Return type: