An asynchronous gRPC client and server runtime for R.
Built on gRPC’s generic asynchronous C++ API
(GenericStub, AsyncGenericService): requests
and responses cross the native boundary as method names plus opaque
bytes, so there is no generated stub code and no protoc
step. Schemas are loaded at runtime, and RProtoBuf supplies
and consumes the message bytes. The two packages split the work cleanly:
RProtoBuf owns descriptors and messages, grpc owns transport.
The package links against the system’s gRPC C++ library: one
apt install on Linux, the copy Rtools already bundles on
Windows, Homebrew’s on macOS. No vendored copies, no Rust toolchain.
That keeps the notorious gRPC library dependence down to the same
packages the rest of the system already uses, at the cost of tying the
binary to the toolchain release it was built on (see
docker/ for the container consequences).
grpc_poll(). A wake descriptor (exposed through
grpc_fd()) integrates with
later::later_fd()-style event loops, so polling is the
fallback, not the primitive.grpc.health.v1, grpc.reflection.v1) are
ordinary proto services here; their schemas ship in
inst/proto/.On Ubuntu (noble) or Debian:
sudo apt install libgrpc++-dev libprotobuf-dev pkgconfOn Windows, Rtools 4.3 or later already carries gRPC and protobuf; there is nothing to install beyond Rtools itself. On macOS:
brew install grpc protobuf pkgconfThen, from a checkout:
R CMD INSTALL .RProtoBuf (apt install r-cran-rprotobuf with r2u, or from CRAN) is a
suggested dependency: raw byte payloads work without it, typed calls
need it.
Client and server in one process, raw bytes, no schema:
library(rgrpc)
srv <- grpc_server("127.0.0.1:0")
cl <- grpc_client(sprintf("127.0.0.1:%d", grpc_server_port(srv)))
call <- grpc_call(cl, "/demo.Echo/Say", as.raw(1:4), deadline_ms = 5000)
answered <- FALSE
while (!answered) {
for (ev in grpc_poll(srv, timeout_ms = 100L)) {
if (ev$type == "request") {
grpc_reply(ev, ev$request)
answered <- TRUE
}
}
}
repeat {
evs <- grpc_await(call, timeout_ms = 1000L)
if (length(evs)) break # empty means "not yet", never "failed"
}
evs[[1]]$status_name # "OK"
evs[[1]]$response # the echoed bytes
grpc_close(cl)
grpc_close(srv)The server accepts any method name; there are no registered handlers, just events. What arrives is the method path, metadata, deadline, peer address (and, under mTLS, the certificate-verified peer identity), and the request bytes.
Note the two ways to receive. grpc_poll() drains one
queue for the whole client or server, so a batch can mix events from
every call in flight, in completion order: iterate it and dispatch on
id. grpc_await() scopes the wait to a single
call and steps over everything else, which is what you want in
sequential code. Indexing a poll batch as evs[[1]] works
only while nothing else is in flight, and stops working silently once
something is.
Either way an empty result means the wait expired, never that the
call failed. grpc_await() leaves the call untouched on
expiry, so awaiting again resumes it; the call’s own
deadline_ms is what bounds the loop. Check the length
before indexing, or a slow peer turns a timeout into
subscript out of bounds.
Load a schema at runtime, resolve the service, and the byte handling disappears:
library(rgrpc)
library(RProtoBuf)
proto <- tempfile(fileext = ".proto")
writeLines(c(
'syntax = "proto3";',
'package demo;',
'message Ping { string msg = 1; }',
'service Echo { rpc Say (Ping) returns (Ping); }'
), proto)
readProtoFiles2(basename(proto), protoPath = dirname(proto))
svc <- grpc_service("demo.Ping", "Echo")
say <- grpc_method(svc, "Say")
srv <- grpc_server("127.0.0.1:0")
cl <- grpc_client(sprintf("127.0.0.1:%d", grpc_server_port(srv)))
call <- grpc_call(cl, say, P("demo.Ping")$new(msg = "hello"),
deadline_ms = 5000)
answered <- FALSE
while (!answered) {
for (ev in grpc_poll(srv, timeout_ms = 100L)) {
if (ev$type == "request") {
req <- grpc_decode(ev$request, say$input_type)
grpc_reply(ev, P("demo.Ping")$new(msg = toupper(req$msg)))
answered <- TRUE
}
}
}
repeat {
evs <- grpc_await(call, timeout_ms = 1000L)
if (length(evs)) break
}
evs[[1]]$response_message$msg # "HELLO"
grpc_close(cl)
grpc_close(srv)Requests are validated against the method’s input type before
sending, and completions carry the decoded response. Streaming methods
work the same way through grpc_stream() /
grpc_send() / grpc_writes_done(), with
per-message stream_msg events and a terminal
stream_status.
The package talks to containerd over its unix socket using the CRI v1
schema vendored under inst/proto/cri:
library(rgrpc)
library(RProtoBuf)
readProtoFiles2("api.proto",
protoPath = system.file("proto", "cri", package = "rgrpc"))
cl <- grpc_client("unix:///run/containerd/containerd.sock")
rt <- grpc_service("runtime.v1.VersionRequest", "RuntimeService")
call <- grpc_call(cl, grpc_method(rt, "Version"),
P("runtime.v1.VersionRequest")$new(), deadline_ms = 2000)
repeat {
evs <- grpc_await(call, timeout_ms = 500L)
if (length(evs)) break
}
evs[[1]]$response_message$runtime_nameinst/examples/cri-list-pods.R is the runnable version
(runtime version, pod sandboxes, images), and the test suite exercises
the server-streaming GetContainerEvents against a live
containerd when the socket is accessible.
Public and pre-release. The API is still allowed to move. PLAN.md
records the design decisions and their evidence;
tools/sanitize.sh is the valgrind/ASan/TSan gate.
Apache License (>= 2), matching gRPC itself.