Status: proposal — design agreed, not yet implemented (2026-07-03).
Issue #903: control writes are blocking
round-trips — control.pitch = x; control.yaw = y measured at ~40 ms — and there is no
non-blocking send path analogous to (receive) streams.
Why writes are slow: the server only processes RPCs inside RPCServerUpdate, once per
FixedUpdate (core/src/Core.cs:403-496), with an adaptive time budget
(MaxTimePerUpdate self-tuning 1–25 ms, Core.cs:380-394). A blocking client waits up
to a full game tick per call, and strictly sequential setters each pay that wait.
What exploration established:
- Batching already exists everywhere except client APIs.
Requestcarriesrepeated ProcedureCall calls;RequestContinuation(core/src/Service/RequestContinuation.cs:19-84) executes multiple calls in order in one tick (yield-aware, completed calls not re-run), with per-call error isolation (ProcedureCallContinuation.Run,ProcedureCallContinuation.cs:35-49) and ordered results; the protocol docs already advertise it (messages.rst:59-60). Every client hard-codes one call per request and readsresults[0]— but every client also already has standaloneProcedureCallbuilders (used by streams): Pythonget_call/_build_call(client.py:150,229), C#GetCall(Connection.cs:169-296), JavabuildCall/getCall(Connection.java:238,362), C++build_call(+invoke(Request)already accepts multi-call,client.cpp:53-120), cnano generated call builders, Lua inline. - Responses are written at exactly one place —
client.Stream.Write(response)inCore.ExecuteContinuation(Core.cs:762) — the natural seam for a no-response flag. Nooneway/no_responseconcept exists anywhere today. - Ordering model: strict FIFO per client — the server will not read a client's next
request while one of its continuations is queued or yielded (the
pollRequestsCurrentClientsguard,Core.cs:683-734); round-robin across clients. Pipelined requests wait in the socket/receive buffer and drain within a tick up to the time budget. - The RPC connection is the only viable channel for client→server pushes. The
stream connection is hard-wired one-way: inbound type
NoMessage, all read paths throw (core/src/Server/Message/StreamStream.cs:15-29), and SerialIO has no stream connection at all (SerialIO/StreamServer.cs:14-24). All three transports (PB/TCP, WebSockets, SerialIO) fully decodeRequest, so new fields onRequestneed zero transport changes, while a new inbound message type would touch every transport. - Prior art for latest-wins-per-tick evaluation is the forward stream loop
(
Core.StreamServerUpdate,Core.cs:503-578); prior art for server-side call storage isExpression.Call(core/src/Service/KRPC/Expression.cs:105-138).
Three rungs, phased; each ships independently and the later ones build on the earlier.
- Batching — client-API work only; no protocol or server change.
- Fire-and-forget —
bool no_response = 2onRequest; server skips the response write. Client use is gated on server version (see version skew below). - Reverse streams — register a call once, push latest-wins argument updates that
the server applies at most once per tick; pushes ride
Requeston the RPC connection; errors invalidate the reverse stream and are delivered over the (forward) stream connection using the #877 error convention.
Cross-cutting: all schema additions are plain proto3 fields/messages — no toolchain issues (Unity protobuf 3.10.1, protobuf-lua).
Semantics (already guaranteed by the server): calls execute in order in a single
request, normally within one tick; each call's error is isolated in its
ProcedureResult and raised only when that call's result is accessed; a call that
yields (multi-tick RPC) delays completion of the whole request — documented, with the
advice to keep long-running calls out of latency-sensitive batches.
Per-client API sketches (exact shapes finalised at implementation):
- Python: a context manager that intercepts
_invoke:Inside the block, stub invocations enqueuewith conn.batch() as batch: control.pitch = 0.1 # queued control.yaw = 0.2 # queued alt = vessel.flight().mean_altitude # queued, returns a BatchResult print(alt.value) # available after the block exits
(ProcedureCall, return_type)pairs and returnBatchResulthandles; on exit, one multi-callRequestis sent and results are decoded per handle. Accessing.valueinside the block raises. Lua mirrors this. - C# / Java: explicit batch objects reusing the existing call builders —
var b = conn.NewBatch(); var r = b.Add(() => vessel.Flight().MeanAltitude); b.Execute(); r.Value. Property setters can't appear in C# expression lambdas, so the batch gains ab.Set(() => control.Pitch, 0.1f)form that derives the setter procedure from the getter expression (theX_get_Y→X_set_Ynaming convention). - C++:
Client::invoke(schema::Request)already accepts multi-call; add akrpc::Batchhelper wrappingbuild_call+ indexed result decoding. - cnano: raise the
calls/resultsnanopb capacities inclient/cnano/src/krpc.optionsand addkrpc_invoke_batch(conn, calls, results, n)(krpc.c:61-104currently hard-codescalls_count = 1and rejectsresults_count != 1).
Schema: Request { repeated ProcedureCall calls = 1; bool no_response = 2; }.
Request-level, not per-call — mixed batches would need partial responses.
Server: thread the flag through Messages.Request → RequestContinuation;
ExecuteContinuation skips client.Stream.Write(response) (Core.cs:762) when set.
Errors from no-response requests are logged at info level and otherwise dropped.
Execution is unchanged — same FIFO ordering, same tick budget; the one-in-flight guard
still applies (a burst of pipelined fire-and-forget requests drains within ticks).
The fence idiom (document): because the server won't read request N+1 until N completes, a subsequent normal RPC's response confirms that all prior fire-and-forget requests have executed — a free flush/barrier.
Version skew — the one hazard: an old server ignores the unknown field and writes a
Response the client never reads, desynchronizing the connection. Client libraries
therefore check the server version (KRPC.GetStatus().version) once, lazily, before
first use of no_response (or reverse streams), and raise a clear "server too old"
error otherwise. Old client + new server is trivially fine.
Client APIs: an option on the batch API (with conn.batch(no_response=True):,
b.Execute(noResponse: true)), which also covers the single-call case.
Schema:
message Request {
repeated ProcedureCall calls = 1;
bool no_response = 2;
repeated ReverseStreamUpdate reverse_updates = 3;
}
message ReverseStreamUpdate {
uint64 id = 1;
repeated Argument arguments = 2; // full replacement argument list
}New KRPC-service RPCs (core/src/Service/KRPC/KRPC.cs):
AddReverseStream(ProcedureCall call) → Stream— validates the procedure and initial arguments, registers, returns an id allocated from the samenextStreamIdcounter as forward streams (shared id space, so the stream-connection error channel is unambiguous). No dedup or refcounting: each add is a fresh id (unlike forward streams there is no bandwidth reason to share, and #902-style sharing surprises are avoided by construction).RemoveReverseStream(uint64 id)— idempotent.
Server registry + executor (core/src/Core.cs):
- Per RPC client:
Dictionary<ulong, ReverseStream>whereReverseStreamholds the procedure signature, the latest decoded argument list, and a dirty flag. PollRequestsappliesreverse_updatesimmediately on read (argument decode + latest-wins replacement + dirty mark; unknown id is silently ignored — it races with removal, matchingRemoveStreamidempotence). A request with only pushes creates no continuation; clients always send pushes withno_response = true(a push withno_response = falsegets an emptyResponseas a synchronous flush).- Each
RPCServerUpdate, after the poll block and before regular continuations: execute every dirty reverse stream once with its latest arguments, clear dirty, counted against the same time budget. Latest-wins: superseded pushes cost only parsing, never execution — this is what makes high-rate control loops backpressure-proof, the property fire-and-forget alone lacks. - Errors invalidate: an exception from the stored call (via
Services.HandleException), or aYieldException(reverse-stream calls must not yield — control setters never do), removes the reverse stream and sends aStreamResult { id, result.error }on the stream connection exactly once — reusing the #877 invalidation convention verbatim; client stream managers route errors for reverse ids to the reverse-stream wrapper. SerialIO caveat: no stream connection exists, so invalidation there is silent (subsequent pushes to the dead id are ignored); documented limitation. - Client disconnect tears down its reverse streams (beside the forward-stream cleanup
in
Core.RPCClientDisconnected/StreamClientDisconnected). - The one-in-flight guard is kept: pushes queued behind an in-flight multi-tick RPC
stall until it completes. Documented, with the recommendation to keep high-rate push
loops on their own connection (a second
Connectionis already supported).
Client APIs (Python first, others mirror):
rs = conn.add_reverse_stream(vessel.control, "pitch") # property-setter form
rs.push(0.5) # non-blocking, latest-wins
rs.remove()plus a generic form for arbitrary procedures,
conn.add_reverse_stream_call(func, *initial_args) with rs.push(*args) replacing the
full argument list (instance handle included — a few bytes). Static clients: a
ReverseStream<T> built from a getter expression (deriving X_set_Y from X_get_Y,
as in the batch Set helper) with .Set(value), plus a raw-ProcedureCall escape
hatch. Wrappers hold the id, mark themselves invalid on an error routed from the
stream manager, and re-raise the stored error on subsequent push — mirroring #877
wrapper semantics.
GetStatus: add a reverse_streams count beside the existing stream stats
(optional, cheap, aids debugging).
doc/src/communication-protocols/messages.rst: no_response semantics + the fence
idiom; ReverseStreamUpdate + the new RPCs; latest-wins and once-per-tick execution;
the error/invalidation convention (cross-referencing the #877 section); SerialIO
limitation. Client docs: batch API, fire-and-forget option, reverse-stream API, and a
rewritten "fast control loops" guidance section (this is the issue's use case).
- Batching: multi-call round trip with mixed success/error results (error isolated
to its handle); ordering; extend
test_performance.py:11-21with a batched variant demonstrating the latency win (assert batched < N× single, loosely — headless-KSP timing is noisy). - Fire-and-forget: no-response setter observed applied via a subsequent read (the fence); ordering across a mix of no-response and normal requests; old-server gating (mock/skip).
- Reverse streams: register + push + read-back next tick; latest-wins (burst of pushes → final value only; TestService gains a counter that increments per applied set, proving superseded pushes never execute); invalidation via a TestService throwing setter → error arrives on the stream connection once, wrapper re-raises, removal idempotent; remove-then-push ignored; disconnect cleanup; per-tick application cadence (headless-KSP giant-timestep caveat: assert on counts, not wall-clock timing).
- Core unit tests for the registry (apply/dirty/latest-wins) where testable
without
Core.Instance.
- Batching client APIs (Python → C#/Java/C++/cnano/Lua) + perf test. No server work.
no_response: schema + server + client batch option + version gating + docs.- Reverse streams server: schema additions, RPCs, registry, executor, error channel (depends on #877's client/server invalidation work being in place).
- Reverse streams client APIs, Python first; remaining clients.
- CHANGES.txt entries per component as the final pre-merge commit.
- #877 invalidation: the reverse-stream error channel is the #877 convention; implement #877 first.
- #902 refcounting: deliberately not applied to reverse streams (each add is a fresh id); the shared id counter is unaffected.
- #843 nullable: pushes carry
Argumentmessages and inheritis_nullsemantics for nullable parameters unchanged. - #901 freezing: orthogonal — freezing gates the client's inbound value application; pushes are outbound.
- Whether
AddReverseStreamshould accept an initialrate(Hz cap on application, like forward-stream rates) — deferred; once-per-tick is the natural cap and control loops want every tick. - Whether push-only requests should bypass the one-in-flight guard (out-of-order with in-flight RPCs, but lower push latency when mixing workloads) — deferred with the separate-connection workaround documented; revisit with real-usage data.