Overview
Welcome to rsbinder!
rsbinder is a Rust library and toolset that enables you to utilize Binder IPC on Linux and Android OS. It provides pure Rust implementations that make Binder IPC available across both platforms.
Binder IPC is an object-oriented IPC (Inter-Process Communication) mechanism that Google added to the Linux kernel for Android. Android uses Binder IPC for all process communication, and the binder driver has been part of the mainline Linux kernel since version 4.17 (with the binderfs filesystem rsbinder uses arriving in 5.0).
However, since it is rarely used outside of Android, it is disabled by default in most Linux distributions, so you typically need a kernel built with binder support to use the kernel-binder path (see Enable binder for Linux).
rsbinder ships two parallel stacks:
- The traditional kernel binder path through
/dev/binder//dev/binderfs/binder, which most of this guide covers. - An RPC transport (binder-over-socket) — AOSP's
RpcServer/RpcSessionanalogue, pure user-space, no kernel driver. Drives the same generated AIDL stubs over Unix-domain sockets, vsock, or TLS, and runs on Linux, Android, and macOS. See RPC Transport.
Crates
rsbinder offers the following crates:
rsbinder: Core library crate for implementing binder service/client functionality.rsbinder-aidl: AIDL-to-Rust code generator for rsbinder.rsbinder-macros: The same generator, reached from a Rust trait instead of an.aidlfile. Not a direct dependency — enablersbinder'smacrosfeature. See Interface Macros.rsbinder-tools: CLI tools — the Binder Service Manager for Linux (rsb_hub), the device setup helper (rsb_device), and the registry inspector (rsb_service).tests: Port of Android's binder test cases for client/server testing.example-hello: Example service/client implementation using rsbinder.
Key Features
- Object-oriented: a call is a method call on a remote object, with strong and weak references that work across processes — not a message you frame yourself.
- Wire-compatible with Android: the same protocol as
libbinder, so an rsbinder client can call a service written in C++ or Java, and the other way round. - Two transport stacks: kernel binder for on-device IPC, and an opt-in RPC transport (Unix sockets, vsock, or TLS) for cross-process, cross-VM, or cross-host calls — both driven by the same AIDL-generated code.
- Cross-platform: Linux and Android (kernel binder), plus macOS (RPC transport only).
- Async/Sync: both programming models, with optional tokio integration.
Core Components
- Parcel: Data serialization and deserialization for IPC transactions
- Binder Objects: Strong and weak references for cross-process communication
- AIDL Compiler: Generates Rust code from Android Interface Definition Language files
- Service Manager: Centralized service discovery and registration (rsb_hub for Linux)
- Thread Pool: Efficient handling of concurrent IPC transactions
- Death Notification: Service lifecycle management and cleanup
- Shared Memory: Payloads too big to copy through a transaction (Shared Memory)
Resources
- API Documentation: docs.rs/rsbinder
- Repository: github.com/hiking90/rsbinder
Before using Binder IPC on Linux, you must enable the feature in the kernel. Please refer to Enable binder for Linux for detailed instructions. For Android, see Android Development.
Architecture
---
title: Binder IPC Architecture
---
flowchart BT
AIDL
G[[Generated Rust Code
for Service and Client]]
S(Your Binder Service)
C(Binder Client)
H(HUB
Service Manager)
AIDL-->|rsbinder-aidl compiler|G;
G-.->|Include|S;
G-.->|Include|C;
S-.->|Register Service|H;
C-.->|Query Service|H;
C<-->|Communication|S;
How It Works
- You define your service interface in an AIDL file
- The rsbinder-aidl compiler generates Rust code (traits, proxies, and stubs)
- Your Service implements the generated trait and registers itself with the HUB (service manager)
- A Client queries the HUB to discover the service, then communicates with it through the generated proxy
Description of each component of the diagram
-
AIDL (Android Interface Definition Language)
- The Android Interface Definition Language (AIDL) is a tool that lets users abstract away IPC. Given an interface (specified in a .aidl file), the rsbinder-aidl compiler constructs Rust bindings so that this interface can be used across processes, regardless of the runtime or bitness.
- The compiler generates both synchronous and asynchronous Rust code with full type safety.
- https://source.android.com/docs/core/architecture/aidl
-
Generated Rust Code
- rsbinder-aidl generates trait definitions, proxy implementations (Bp*), and native service stubs (Bn*).
- Includes Parcelable implementations for data serialization/deserialization.
- Supports both sync and async programming models with async-trait integration.
- Features automatic memory management and error handling.
-
Your Binder Service
- Implement the generated trait interface to create your service logic.
- Use BnServiceName::new_binder() to create a binder service instance.
- Register your service with the HUB using hub::add_service().
- Join the thread pool to handle incoming client requests.
- Supports both native services and async services with runtime integration.
-
Binder Client
- Use hub::wait_for_interface() to obtain a strongly-typed proxy to the service (or hub::check_interface() / hub::try_get_interface() for non-blocking lookup).
- The generated proxy code handles all IPC marshalling/unmarshalling automatically.
- Supports death notifications for service lifecycle management.
- Can register service callbacks for service availability notifications.
- Full type safety with compile-time interface validation.
-
HUB (Service Manager)
- In rsbinder, the service manager is referred to as HUB. The
hubmodule in the rsbinder API provides a unified interface (hub::add_service(),hub::wait_for_interface(), etc.) that works on both platforms. - On Linux: rsbinder provides rsb_hub, a standalone service manager that you run as a separate process. You must start
rsb_hubbefore registering or discovering services. - On Android: The system already provides its own service manager (
servicemanager). rsbinder connects to it automatically — no need to runrsb_hub. - Handles service registration, discovery, and lifecycle management.
- Provides APIs for listing services, checking service status, and notifications.
- In rsbinder, the service manager is referred to as HUB. The
The RPC transport (a separate, opt-in stack)
The diagram above describes the kernel binder path. rsbinder also ships a second, independent stack — RPC transport — that delivers the same generated AIDL stubs over a socket instead of the kernel binder driver:
┌──────────┐ AIDL stub ┌──────────┐
│ Client │ ─── try_from(root) ──▶ │ Server │
└────┬─────┘ └────┬─────┘
│ │
RpcSession RpcServer
.setup_unix_client() .setup_unix_server(path)
.get_root() .set_root(BnFoo)
│ │
└──── UDS / vsock / TLS socket ─────┘
Key contrasts with the kernel-binder diagram:
- No service manager. The server publishes a single root binder
via
RpcServer::set_root; clients fetch it throughRpcSession::get_root. (Multiple named services on one server are available viaRpcServer::add_service.) - No kernel driver.
ProcessState,rsb_hub, and/dev/binderfs/binderare not involved at all. A pure-RPC process needs none of them. - Cross-host / cross-VM by design. Sockets reach further than
/dev/binderdoes — Linux ↔ macOS, host ↔ VM (vsock), or authenticated peers across a network (TLS).
Both stacks coexist in the same process — for example, the Accessor
pattern (Android 16+) publishes an IAccessor binder over kernel
binder whose addConnection() hands the client a connected RPC
socket fd.
Coexistence rules
If a single process uses both stacks (the Accessor pattern is the canonical example):
- Kernel-binder side requires
ProcessState::init_default()(orinit(path, max_threads)) and typicallystart_thread_pool()/join_thread_pool(). This is global per-process state and lives in thersbindercore. - RPC side is fully independent —
RpcServer/RpcSessionopen their own sockets and run their own accept/worker threads. They do not touchProcessState,rsb_hub, or/dev/binder. - A pure-RPC process omits the kernel-binder initialization entirely;
a pure-kernel process omits the RPC code path entirely (and doesn't
even need the
rpcCargo feature). Mixing the two costs only what each side already costs in isolation — there is no shared singleton between them. - Binder objects do not cross between the two. Writing a proxy of one
stack into a parcel of the other is refused with
InvalidOperation(AOSP does the same). To let a client of one stack reach a service on the other, use an Accessor or a gateway — Cross-Transport Services § Bridging.
See RPC Transport for the full story.
Parcel byte order
The data-parcel wire is little-endian on every host, so a parcel written on
one machine reads on another and the bytes match what Android's libbinder
sends for the same value. On a little-endian host — every Android target and
almost every Linux one — this costs nothing; a big-endian host pays a byte
swap.
Not every byte in a parcel is wire:
| Layer | What | Byte order |
|---|---|---|
| Data parcel | scalars, arrays, String, the object-free payload | little-endian |
| Kernel command stream | the BC_* / BR_* ioctl buffer | host-native |
| UAPI structs | flat_binder_object, binder_transaction_data | host-native |
The lower two go to the kernel driver, which parses them with native loads. So on a big-endian host a kernel parcel carrying a binder is a mixture — little-endian scalars around a native object header — which is correct: the scalars cross to a peer, the object header does not.
The RPC path is verified on big-endian under qemu-user (s390x). The kernel path is structurally correct there but unverified, because no big-endian system ships binderfs to run it on.
Getting Started
Pick a stack
rsbinder ships two, and which one you can use depends on the platform:
- Kernel binder — Linux 5.0+ with binderfs enabled, or Android. Talks to
the kernel driver through
/dev/binderfs/binder(Linux) or/dev/binder(Android). This is what most of this guide covers. - RPC transport (binder-over-socket, opt-in via the
rpcfeature) — pure user-space, no kernel driver, no root. Runs on Linux, Android, and macOS, over Unix-domain sockets, vsock, or TLS. See RPC Transport.
macOS has no binder driver, so only the RPC stack works there — enough to develop and test RPC services on a workstation without a Linux VM. Windows is not supported on either stack.
Before you start
- Rust 1.86 or later
- For kernel binder: a kernel with binder support, or an Android device/emulator
-
For kernel binder on Linux: a device node created with
rsb_device, andrsb_hubrunning as the service manager
Installation walks through all of these.
The workflow
- Define the interface — an
.aidlfile, or a Rust trait with#[rsbinder::interface]. - Generate the Rust from it (
rsbinder-aidlin abuild.rs; the macro needs no build step). - Implement the service.
- Register it and connect to it —
rsbinder::serve(uri)andrsbinder::connect(uri).
Hello, World! does all four end to end, and is the fastest way to see where each piece fits.
Installation
Prerequisites
Rust Version Requirements
rsbinder requires Rust 1.86 or later. Ensure you have the latest stable Rust toolchain:
$ rustup update stable
$ rustc --version # Should be 1.86+
Enable binder for Linux
Please refer to Enable binder for Linux for detailed instructions on setting it up.
Create binder device file for Linux
Once the kernel has binder support, create the device node.
First, create the group that will own the device and put yourself in it (log
out and back in, or run newgrp binder, for the membership to take effect):
$ sudo groupadd -f binder
$ sudo usermod -aG binder "$USER"
Then install rsbinder-tools and create the device:
$ cargo install rsbinder-tools
$ sudo rsb_device binder --group binder --mode 0660
Or, building from source:
$ git clone https://github.com/hiking90/rsbinder.git
$ cd rsbinder
$ cargo build --release
$ sudo target/release/rsb_device binder --group binder --mode 0660
rsb_device creates /dev/binderfs if it does not exist, mounts binderfs,
creates the named device, and applies --group and --mode.
The mode is not a formality: binder has no in-kernel access control of its
own, so the device node is the only thing deciding who can speak binder at
all — the same model as /dev/kvm being 0660 root:kvm. It defaults to
0600, root only; grant access deliberately with --group.
Run a service manager for Linux
Installing rsbinder-tools also installs rsb_hub.
rsb_hub denies every request that its policy does not allow, and refuses to
start when it cannot load one, so a first run needs one of two things. For a
quick local try-out, run with no access control at all:
$ rsb_hub --insecure-allow-all
For anything else, write a policy — see
Access control — and point rsb_hub at
it (/etc/rsbinder/hub.d is the default):
$ rsb_hub --config /etc/rsbinder/hub.d
Alternatively, if building from source:
$ cargo run --bin rsb_hub -- --insecure-allow-all
rsb_hub is the registry every service registers with and every client looks
up through. It also enforces per-name access control, and can declare and
start services on demand — see Service Manager.
Using a custom binder device path
By default, rsb_hub uses /dev/binderfs/binder. If you created a binder device with a different name, use the --device (-d) option:
$ rsb_hub --device custom_binder
# or
$ rsb_hub -d custom_binder
In your Rust code, use ProcessState::init() instead of ProcessState::init_default() to specify the matching path:
#![allow(unused)] fn main() { // Use a custom binder device path ProcessState::init( "/dev/binderfs/custom_binder", rsbinder::DEFAULT_MAX_BINDER_THREADS, )?; }
Important: The service manager, service, and client must all use the same binder device path.
Dependencies for rsbinder projects
Add the following configuration to your Cargo.toml file:
[dependencies]
rsbinder = "0.12"
async-trait = "0.1"
env_logger = "0.11" # Optional: for logging
[build-dependencies]
rsbinder-aidl = "0.12"
Feature flags
rsbinder = "0.12" # default: tokio async
rsbinder = { version = "0.12", default-features = false } # synchronous only
rsbinder = { version = "0.12", features = ["rpc", "macros"] }
tokio(default) — Tokio integration (impliesasync). It enables onlytokio/rt,rt-multi-threadandsync; declaremacros,time,netand the rest on your owntokiodependency.async— async-trait support without tokio, for a different runtime.rpc— the RPC transport (binder-over-socket). Addrpc-vsock,rpc-tls, orrpc-tcp-debugfor the matching backend. See RPC Transport.macros—#[rsbinder::interface]and theParcelable/BinderEnumderives: an interface as a Rust trait, with no.aidlfile and nobuild.rs. See Interface Macros. Also theServiceSpecificErrorderive (see Error Handling).android_*— Android version compatibility (see Android Development).
What each crate is for
- rsbinder — the core library: kernel communication, parcel serialization, thread pool, service lifecycle.
- rsbinder-aidl — the AIDL-to-Rust generator, used from
build.rs. - async-trait — required by the async interfaces
rsbinder-aidlgenerates. - env_logger — optional, but the fastest way to see what rsbinder is doing.
Hello World!
This tutorial will guide you through creating a simple Binder service that echoes a string back to the client, and a client program that uses the service.
Create a new Rust project
Create a library project for the common library used by both the client and service:
$ cargo new --lib hello
Modify Cargo.toml
In the hello project's Cargo.toml, add the following dependencies:
[package]
name = "hello"
version = "0.1.0"
publish = false
edition = "2021"
[dependencies]
rsbinder = "0.12"
async-trait = "0.1"
env_logger = "0.11"
[build-dependencies]
rsbinder-aidl = "0.12"
Create an AIDL File
Create an aidl folder in the project's top directory to manage AIDL files:
$ mkdir -p aidl/hello
$ touch aidl/hello/IHello.aidl
Directory ↔ package mapping. The folder name under
aidl/must match thepackagedeclaration at the top of the.aidlfile. Soaidl/hello/IHello.aidlrequirespackage hello;(below), and the generated Rust path iscrate::hello::IHello::IHello. A nested package likepackage com.example.hello;would needaidl/com/example/hello/IHello.aidl. This mirrors Android's AIDL convention.
Create the aidl/hello/IHello.aidl file with the following contents:
package hello;
// Defining the IHello Interface
interface IHello {
// Defining the echo() Function.
// The function takes a single parameter of type String and returns a value of type String.
String echo(in String hello);
}
For more information on AIDL syntax, refer to the Android AIDL documentation.
Create the build.rs
Create a build.rs file in the project root (next to Cargo.toml) to compile the AIDL file and generate Rust code:
use std::path::PathBuf; fn main() { rsbinder_aidl::Builder::new() .source(PathBuf::from("aidl/hello/IHello.aidl")) .output(PathBuf::from("hello.rs")) .generate() .unwrap(); }
Important: The
build.rsfile must be placed in the project root directory, not insidesrc/. If placed in the wrong location, you will get a compile error:environment variable OUT_DIR not defined at compile time. Cargo only recognizesbuild.rsat the project root.
Create a common library for Client and Service
For the Client and Service, create a library that includes the Rust code generated from AIDL.
Create src/lib.rs and add the following content.
#![allow(unused)] fn main() { // Include the code hello.rs generated from AIDL and re-export the // IHello interface items (the trait, BnHello, BpHello, ...) so both // the client and the service can use them. rsbinder::include_aidl!("hello", crate::hello::IHello::*); // Define the name of the service to be registered in the HUB(service manager). pub const SERVICE_NAME: &str = "my.hello"; }
The rsbinder::include_aidl! macro is the one-call form of the manual
include/re-export pair. The above is equivalent to:
#![allow(unused)] fn main() { include!(concat!(env!("OUT_DIR"), "/hello.rs")); pub use crate::hello::IHello::*; }
Create a service
Create the src/bin/ directory and add the service file. Cargo automatically recognizes .rs files under src/bin/ as binary targets, so no [[bin]] section is needed in Cargo.toml.
Create src/bin/hello_service.rs:
use env_logger::Env; use rsbinder::*; use hello::*; // Define a struct that implements the IHello interface. struct IHelloService; // Implement the IHello interface for the IHelloService. impl Interface for IHelloService { // Reimplement the dump method. This is optional. fn dump(&self, writer: &mut dyn std::io::Write, _args: &[String]) -> Result<()> { writeln!(writer, "Dump IHelloService")?; Ok(()) } } // Implement the IHello interface for the IHelloService. impl IHello for IHelloService { // Implement the echo method. fn echo(&self, echo: &str) -> rsbinder::BinderResult<String> { Ok(echo.to_owned()) } } fn main() -> std::result::Result<(), Box<dyn std::error::Error>> { env_logger::Builder::from_env(Env::default().default_filter_or("warn")).init(); // Create a binder service. println!("Creating service..."); let service = BnHello::new_binder(IHelloService{}); // `serve(Uri::kernel())` initializes the kernel binder process state, // `add` names the service (it takes anything convertible into // `SIBinder`), and `run` starts the binder thread pool, registers the // service with the service manager and joins the pool — this blocks // for the life of the service. println!("Serving {SERVICE_NAME}..."); rsbinder::serve(Uri::kernel())? .add(SERVICE_NAME, &service)? .run()?; Ok(()) }
The same three calls with
serve(Endpoint::unix("/tmp/hello.sock"))serve the service over a Unix socket instead of kernel binder — see Cross-Transport Services. The low-level form (ProcessState::init_default(),start_thread_pool(),hub::add_service(),join_thread_pool()) remains available.
Create a client
Create the src/bin/hello_client.rs file and configure it as follows.
#![allow(non_snake_case)] use env_logger::Env; use rsbinder::*; use hello::*; use hub::{BnServiceCallback, IServiceCallback}; use std::sync::Arc; struct MyServiceCallback {} impl Interface for MyServiceCallback {} impl IServiceCallback for MyServiceCallback { fn onRegistration(&self, name: &str, _service: &SIBinder) -> rsbinder::BinderResult<()> { println!("MyServiceCallback: {name}"); Ok(()) } } struct MyDeathRecipient {} impl DeathRecipient for MyDeathRecipient { fn binder_died(&self, _who: &WIBinder) { println!("MyDeathRecipient"); } } fn main() -> std::result::Result<(), Box<dyn std::error::Error>> { env_logger::Builder::from_env(Env::default().default_filter_or("warn")).init(); // `connect` on a kernel `Uri` with a service name initializes the kernel // binder process state, blocks until the service is registered (the // event-driven equivalent of AOSP's `waitForService`), and casts it to // the interface. let hello: rsbinder::Strong<dyn IHello> = rsbinder::connect(Uri::kernel().with_service(SERVICE_NAME))?; println!("list services:"); // Service-manager extras stay on `hub`. for name in hub::list_services(hub::DUMP_FLAG_PRIORITY_DEFAULT) { println!("{name}"); } let service_callback = BnServiceCallback::new_binder(MyServiceCallback {}); hub::register_for_notifications(SERVICE_NAME, &service_callback)?; // `link_to_death_arc` takes the concrete `Arc<MyDeathRecipient>` directly. // Keep `recipient` alive for the link's lifetime (the link holds only a weak ref). let recipient = Arc::new(MyDeathRecipient {}); hello.link_to_death_arc(&recipient)?; // Call echo method of Hello proxy. let echo = hello.echo("Hello World!")?; println!("Result: {echo}"); Ok(ProcessState::join_thread_pool()?) }
Project folder and file structure
.
├── Cargo.toml
├── aidl
│ └── hello
│ └── IHello.aidl
├── build.rs
└── src
├── bin
│ ├── hello_client.rs
│ └── hello_service.rs
└── lib.rs
Run Hello Service and Client
Before running the service and client, start the service manager. It refuses
to start without an access-control policy, so a local try-out passes
--insecure-allow-all — see Access control
for the real thing:
# In terminal 1: Start the service manager
$ rsb_hub --insecure-allow-all
Now you can run the service and client:
# In terminal 2: Run the service
$ cargo run --bin hello_service
# In terminal 3: Run the client
$ cargo run --bin hello_client
Expected Output
hello_service output:
Creating service...
Serving my.hello...
hello_client output:
list services:
my.hello
manager
MyServiceCallback: my.hello
Result: Hello World!
Along the way the client also listed the registered services, registered for availability notifications, and linked a death recipient.
Troubleshooting
- "ProcessState is not initialized!" —
rsbinder::serve(Uri::kernel())/rsbinder::connect(Uri::kernel().with_service(…))(or the low-levelProcessState::init_default()) must run before any other rsbinder API that touches the binder device. - "environment variable OUT_DIR not defined" —
build.rsmust be in the project root, next toCargo.toml, not insidesrc/. - Client blocks without output —
rsbinder::connecton a kernelUriwaits until the service is registered; make sure the service is running. - Permission errors — the binder device node defaults to
0600, root only. Give it a group withrsb_device binder --group <group> --mode 0660and make sure you are in that group. - Service manager not found — check that
rsb_hubis running, and that it did not exit at startup for want of a policy.
Next Steps
- AIDL Data Types — how AIDL types map to Rust: primitives, arrays, strings, nullable types
- Parcelable — custom data structures that cross the Binder boundary
- Enum and Union — enum and union types in your interfaces
- Annotations —
@RustDerive,@Backing,@nullable, and the rest - Interface Macros — the same interface as a Rust trait, with no
.aidlfile - Service Patterns —
dump(), default implementations, multi-service processes - Async Service — async/await on the tokio runtime
- Callbacks and Interfaces — bidirectional communication and death recipients
- ParcelFileDescriptor — passing file descriptors across processes
- Error Handling — service-specific errors, status codes, exceptions
- Security & Authorization — identifying the caller and deciding what it may do
- Service Manager (HUB) — registration, lookup, notifications, debug info
- docs.rs/rsbinder — the full API reference
Run the Test Suite
The rsbinder project includes a comprehensive test suite ported from Android:
# Terminal 1: Start service manager
$ cargo run --bin rsb_hub -- --insecure-allow-all
# Terminal 2: Start test service
$ cargo run --bin test_service
# Terminal 3: Run tests
$ cargo test -p tests test_client::
Study Real Examples
Check out the rsbinder repository for more complex examples:
- example-hello: The complete example from this tutorial (note: the package name is
example-hello, so import paths useexample_hello::*instead ofhello::*) - tests: Comprehensive test cases showing various IPC scenarios
- rsbinder-tools: Real-world service manager implementation
AIDL Guide
AIDL (Android Interface Definition Language) is the contract between a Binder service and its clients. You describe the interface — its methods, parameters, and data types — in a .aidl file, and the rsbinder-aidl compiler generates the Rust code on both sides. This guide walks through the parts of AIDL that you will actually use when writing services with rsbinder.
If you have not yet seen rsbinder running end-to-end, read Hello, World! first — it shows where AIDL fits into a complete project.
With rsbinder on both ends, Interface Macros declares the same interface as a Rust trait instead — same compiler, same generated code. Read this guide either way: it describes the type system both paths share.
Chapters
- Data Types — Primitive, string, array, list, map, and interface types, and how each one maps to Rust on the
in/out/inoutside. Start here. - Parcelable — Defining user-supplied structs that can cross the Binder boundary, including nullable fields and default values.
- Enum and Union — Backed enums (newtype structs in Rust, for wire-stable forward compatibility) and unions (tagged variants).
- Annotations —
@RustDerive,@nullable,@Backing,@JavaDerive-equivalents, and the other annotations the Rust backend honors.
Read them in that order the first time; Annotations is a reference to come back to when the generated Rust does not look the way you expected.
What the compiler rejects
rsbinder-aidl rejects the .aidl that AOSP's aidl rejects, so a contract written here also builds for the other backends. Each diagnostic names the rule. Besides the @FixedSize and @VintfStability rules in Annotations, it rejects:
ParcelableHolderanywhere but a parcelable field: as a method argument or return type, a union member, an array orListelement, or@nullable.voidanywhere but a method return type.- A
unionwith no fields (constmembers do not count). - A duplicate method name in an interface, or a duplicate argument name in a method.
- A type argument on a type that takes none (
String<int>,IBinder<T>,Plain<int>on a non-generic parcelable), or the wrong number of them. - A type argument that does not meet its parameter's requirement (
MQDescriptor<String, …>where the parameter is@FixedSize T), or that is an array, aList,voidorParcelableHolder. - A generic parcelable field whose type is one of the parameters (
parcelable Foo<T> { T value; }), and a genericunion. See Generic Parcelables. - A
constwhose type is not a primitive, aString, or an array of those.
rsbinder keeps boolean, char and array constants, which AOSP refuses.
rsbinder-aidl also refuses an AIDL name that collides with an item the generated Rust adds next to it, with a diagnostic rather than a rustc duplicate-definition error in the generated file under OUT_DIR. For an interface IFoo, those items are BnFoo, BpFoo, IFooDefault, IFooDefaultRef and a transactions module, with async codegen also BnFooAdapter, IFooAsync and IFooAsyncService, the module-level DEFAULT_IMPL and on_transact, plus VERSION and HASH when a version or hash is set; a union adds Tag, and an enum (a union's Tag included) adds the methods get and enum_values. A nested type, constant, enumerator or union field with one of those names — const int VERSION = 1; in a versioned interface, a nested parcelable Tag in a union, a union field named get — needs another name. AOSP's aidl also refuses a union named Tag and a nested Tag in a union; the other names come from the Rust backend.
Type names starting with __Rsb are refused as well, which AOSP's aidl accepts. The async codegen declares its type parameters and a helper struct (__RsbPool, __RsbService, __RsbRuntime, __RsbAsyncWrapper) in the scope where method signatures are resolved, so a nested type of the same name would resolve to the generated item.
The generated Rust also spells some names without a path, and those names are not refused: a nested type named Vec, Box, Option, String, Default or rsbinder. A nested type becomes a module next to the signatures, fields and bodies of the type that contains it, which write BinderResult<Vec<i32>>, Option<String>, Box<…>, Default::default() and rsbinder::…, so each of those names resolves to the nested type and the generated file under OUT_DIR fails with a rustc error, not an AIDL diagnostic. A fixed-size array field longer than 32 elements is initialized through std::array::from_fn, so a nested type named std breaks that field the same way. AOSP's Rust backend writes Vec and Box with a full path and accepts such a contract; here the nested type needs another name. A generic parcelable's type parameter with one of these names is refused (see Generic Parcelables).
Then move on to Service Development for the runtime patterns that put these types to work.
AIDL Data Types
AIDL (Android Interface Definition Language) defines the interface contract between a Binder service and its clients. When you write an .aidl file, rsbinder-aidl generates Rust code that maps each AIDL type to the corresponding Rust type. Understanding these mappings is essential for implementing services and calling them correctly from client code.
Primitive Types
The following table shows how AIDL primitive types map to Rust types. Input parameters (in) are passed by value or by reference. Only non-scalar types — arrays, parcelables, and nullable references — may be out/inout; the generator rejects a primitive or a String marked out/inout at compile time (it has no fixed slot to write back into).
| AIDL Type | Rust Type (in) | Rust Type (out) | Notes |
|---|---|---|---|
| boolean | bool | — (not allowed) | |
| byte | i8 | — (not allowed) | Single values use i8; array Reverse uses u8 |
| char | u16 | — (not allowed) | UTF-16 code unit |
| int | i32 | — (not allowed) | |
| long | i64 | — (not allowed) | |
| float | f32 | — (not allowed) | |
| double | f64 | — (not allowed) | |
| String | &str | — (not allowed) | out/inout String is rejected by the generator |
| @utf8InCpp String | &str | — (not allowed) | Same mapping in rsbinder |
| T[] | &[T] | &mut Vec<T> | |
| @nullable T | Option<&T> | &mut Option<T> | A nullable String input is Option<&str> |
| @nullable T[] | Option<&[T]> | &mut Option<Vec<T>> | For a primitive or enum element. A non-primitive element keeps its own Option in every direction and at every arity: Option<&[Option<T>]>, &mut Option<Vec<Option<T>>>, and for T[N] Option<&[Option<T>; N]> (Option<[Option<T>; N]> as a return value), as AOSP's Rust backend renders them |
| IBinder | &SIBinder | &mut Option<SIBinder> | |
| ParcelFileDescriptor | &ParcelFileDescriptor | &mut Option<ParcelFileDescriptor> | |
| An interface | &Strong<dyn I> | &mut Option<Strong<dyn I>> | |
A generic parcelable Foo<A, B> | &Foo<A, B> | &mut Foo<A, B> | The arguments follow the type everywhere it appears; see Generic Parcelables |
Here is an AIDL interface that exercises the primitive types:
interface IDataService {
boolean RepeatBoolean(boolean token);
byte RepeatByte(byte token);
int RepeatInt(int token);
long RepeatLong(long token);
float RepeatFloat(float token);
double RepeatDouble(double token);
}
The generated Rust trait expects the following signatures. A service implementation simply returns each value back to the caller:
#![allow(unused)] fn main() { impl IDataService for MyService { fn RepeatBoolean(&self, token: bool) -> rsbinder::BinderResult<bool> { Ok(token) } fn RepeatByte(&self, token: i8) -> rsbinder::BinderResult<i8> { Ok(token) } fn RepeatInt(&self, token: i32) -> rsbinder::BinderResult<i32> { Ok(token) } // ... similar for other types } }
Each method returns rsbinder::BinderResult<T>, which allows the service to return either a value or a Status error to the client.
String Types
AIDL String maps to &str for input parameters and String for return values. This follows Rust's standard convention of borrowing for inputs and returning owned data for outputs.
The @utf8InCpp annotation exists in Android AIDL to distinguish between UTF-16 and UTF-8 string encodings in the C++ backend. In Android's C++ Binder, strings are UTF-16 by default and @utf8InCpp switches them to std::string (UTF-8). In rsbinder, this annotation has no effect because Rust strings are always UTF-8. Both String and @utf8InCpp String produce the same Rust type mapping.
A simple service method that echoes a string back to the caller looks like this:
#![allow(unused)] fn main() { fn RepeatString(&self, input: &str) -> rsbinder::BinderResult<String> { Ok(input.into()) } }
Note the use of .into() to convert the borrowed &str into an owned String for the return value. You can also use input.to_string() or input.to_owned() -- all three are equivalent here.
Arrays and the Reverse Pattern
A common pattern in AIDL test interfaces is the "Reverse" method. The method receives an input array, copies it into an out parameter called repeated, and returns the reversed array. This exercises both input and output array handling in a single call.
The AIDL definition looks like this:
int[] ReverseInt(in int[] input, out int[] repeated);
In the generated Rust trait, the in parameter becomes a slice reference (&[i32]) and the out parameter becomes a mutable reference to a Vec (&mut Vec<i32>). The return value is also a Vec:
#![allow(unused)] fn main() { fn ReverseInt(&self, input: &[i32], repeated: &mut Vec<i32>) -> rsbinder::BinderResult<Vec<i32>> { repeated.clear(); repeated.extend_from_slice(input); Ok(input.iter().rev().cloned().collect()) } }
On the client side, you pass the input array and a mutable Vec to receive the repeated copy. After the call returns, both the repeated vector and the return value are populated:
#![allow(unused)] fn main() { let input = vec![1, 2, 3]; let mut repeated = vec![]; let reversed = service.ReverseInt(&input, &mut repeated)?; assert_eq!(repeated, vec![1, 2, 3]); assert_eq!(reversed, vec![3, 2, 1]); }
This pattern applies to all array types, including boolean[], byte[], long[], float[], double[], String[], and arrays of parcelable types. The Reverse pattern is particularly useful in testing because it validates that data survives a round trip through Binder serialization and deserialization in both directions.
Nullable Types
The @nullable annotation indicates that a parameter or return value may be absent. In Rust, this maps naturally to Option<T>.
For input parameters, a nullable array becomes Option<&[T]>. For return values, it becomes Option<Vec<T>>. This allows both the client and service to represent the absence of a value without resorting to sentinel values or empty collections.
AIDL definition:
@nullable int[] RepeatNullableIntArray(in @nullable int[] input);
Rust service implementation:
#![allow(unused)] fn main() { fn RepeatNullableIntArray(&self, input: Option<&[i32]>) -> rsbinder::BinderResult<Option<Vec<i32>>> { Ok(input.map(<[i32]>::to_vec)) } }
Client usage:
#![allow(unused)] fn main() { let result = service.RepeatNullableIntArray(Some(&[1, 2, 3])); assert_eq!(result, Ok(Some(vec![1, 2, 3]))); let result = service.RepeatNullableIntArray(None); assert_eq!(result, Ok(None)); }
When None is passed, the Binder transaction sends a null marker and the service receives None. When a value is present, it is serialized and deserialized normally.
The @nullable annotation can also be applied to String, IBinder, and parcelable types. Without @nullable, these types must always be present -- passing a null value will result in a transaction error.
Parameter Direction: in, out, and inout
AIDL parameters have a direction tag that controls how data flows between client and service. This affects both the wire format (what data is serialized into the Binder transaction) and the generated Rust method signatures.
in (default)
Data flows from the client to the service. This is the default direction and does not need to be specified explicitly (though you can write it for clarity). In Rust, in parameters are passed by value for primitives or by reference for complex types like arrays and strings.
void Process(in int[] data); // explicit 'in'
void Process(int[] data); // same as above, 'in' is the default
For primitive types like int and boolean, the in direction simply means the value is copied into the Binder transaction. For complex types like arrays, a slice reference (&[T]) is used so the data is serialized without requiring the caller to give up ownership.
out
Data flows from the service back to the client. The client provides a mutable container and the service fills it with data. In Rust, out parameters are passed as &mut references. The initial contents of the container are not sent to the service -- only the service's written data is transmitted back.
void GetData(out int[] result);
In Rust, this generates a &mut Vec<i32> parameter. The caller should provide an empty or pre-allocated vector; the service is responsible for populating it.
inout
Data flows in both directions. The client sends initial data to the service, the service may modify it, and the modified data is sent back. In Rust, inout parameters are also passed as &mut references, but unlike out parameters, the initial value is serialized and sent to the service.
void Transform(inout int[] data);
Use inout when the service needs to read the existing value and modify it in place. Prefer in or out when data only needs to flow in one direction, as this avoids unnecessary serialization overhead.
Note: Primitive types (
boolean,byte,char,int,long,float,double) andStringcannot carry anout/inoutdirection tag — the generator rejects it. As scalar (or, forString, value-typed) parameters they only ever flowin. Direction tags are meaningful only for arrays and parcelable types.
Builtin AOSP Types
Two AOSP packages compile without a vendored .aidl: an import of one of
these names resolves to a type the rsbinder crate provides, and the
generated code refers to it by that path.
| AIDL import | Rust type |
|---|---|
android.os.ParcelFileDescriptor | rsbinder::ParcelFileDescriptor |
android.hardware.common.NativeHandle | rsbinder::NativeHandle |
android.hardware.common.fmq.MQDescriptor | rsbinder::fmq::MQDescriptor<T, F> |
android.hardware.common.fmq.GrantorDescriptor | rsbinder::fmq::GrantorDescriptor |
android.hardware.common.fmq.SynchronizedReadWrite | rsbinder::fmq::SynchronizedReadWrite |
android.hardware.common.fmq.UnsynchronizedWrite | rsbinder::fmq::UnsynchronizedWrite |
import android.hardware.common.fmq.MQDescriptor;
import android.hardware.common.fmq.SynchronizedReadWrite;
interface IProducer {
// Generated: fn openQueue(&self) -> BinderResult<rsbinder::fmq::MQDescriptor<i8, rsbinder::fmq::SynchronizedReadWrite>>
MQDescriptor<byte, SynchronizedReadWrite> openQueue();
}
The hardware/interfaces sources are VINTF-stable and unchanged since
Android 11, so the wire format matches a C++ or Java HAL. A copy of one of
these files under an include directory takes precedence and is compiled like
any other source. rsbinder::fmq also re-exports the rsbinder-fmq crate —
the queue itself — on every platform, with a conversion between
MQDescriptor and its Descriptor; making or attaching a queue works on
Linux and Android. See the module documentation. A
descriptor returned or accepted by a generated service reaches AOSP's own
AidlMessageQueue (libfmq over libbinder_ndk) intact in both directions,
and a queue libfmq made as an ashmem region attaches on the rsbinder side:
example-hello/cpp/run_fmq_interop.sh is that check on an emulator.
Tips
charis not Rust'schar. AIDLcharis one UTF-16 code unit and maps tou16; Rust'scharis a Unicode scalar value. They are not interchangeable.byteis signed alone and unsigned in an array. Abyteparameter isi8, butbyte[]elements areu8— matching how Android treats byte arrays.- Every method returns
BinderResult<T>, a void one included (BinderResult<()>), because any call can fail in transport.
For more information on AIDL syntax and features, refer to the Android AIDL documentation.
Parcelable
A parcelable is a user-defined struct that can cross the Binder boundary. You declare it in an .aidl file and rsbinder-aidl generates the Rust struct and its codec — it is how any non-trivial interface passes structured data.
Basic Parcelable Definition
A parcelable is declared in its own .aidl file using the parcelable keyword. Here is a simple example:
package com.example;
@RustDerive(Clone=true, PartialEq=true)
parcelable UserProfile {
int id;
String name = "Unknown";
int age = 0;
}
When this AIDL file is processed by rsbinder-aidl, it generates a Rust struct that you can use directly in your service and client code. Several things to note about the definition above:
@RustDerive(Clone=true, PartialEq=true)instructs the code generator to add#[derive(Clone, PartialEq)]to the generated Rust struct. By default, generated types do not deriveClone, because some AIDL types contain non-cloneable fields (such asParcelFileDescriptororParcelableHolder). You must opt in explicitly for each type.- Default values (
"Unknown"forname,0forage) are applied in the generatedDefaulttrait implementation. Fields without explicit defaults use Rust's default for their type (e.g.,0for integers, empty string forString). - The generated struct can be used directly as a parameter or return type in service interface methods.
You can then use the generated struct in Rust:
#![allow(unused)] fn main() { use com::example::UserProfile::UserProfile; let profile = UserProfile { id: 1, name: "Alice".into(), age: 30, }; let default_profile = UserProfile::default(); assert_eq!(default_profile.name, "Unknown"); assert_eq!(default_profile.age, 0); }
Constants in Parcelable
Parcelable types can define constants, including numeric values and bit flags. These are emitted as module-level pub const items inside the generated parcelable module — not as associated constants on the struct. The familiar-looking TypeName::CONSTANT path therefore resolves through the module name (which shares its name with the struct), so you must import the module, not the struct: use ...::Config; gives you both Config::BIT_VERBOSE (the constant) and Config::Config (the struct). Importing the struct directly (use ...::Config::Config;) shadows the module path and makes the constants unreachable through it. This pattern is commonly used for configuration values and flag fields.
@RustDerive(Clone=true, PartialEq=true)
parcelable Config {
const int MAX_RETRIES = 5;
const int BIT_VERBOSE = 0x1;
const int BIT_DEBUG = 0x4;
int retryCount = MAX_RETRIES;
int flags = 0;
String label = "default";
}
In Rust, the constants are accessed through the module path, and the struct through the module-qualified Config::Config spelling:
#![allow(unused)] fn main() { use com::example::Config; let mut cfg = Config::Config::default(); assert_eq!(cfg.retryCount, 5); cfg.flags = Config::BIT_VERBOSE | Config::BIT_DEBUG; assert_eq!(cfg.flags, 0x5); }
This pattern mirrors how the Android test suite defines and uses bit flags within StructuredParcelable, where constants like BIT0, BIT1, and BIT2 are defined alongside the fields that use them.
Using Parcelable in Services
Parcelable types are passed to and from service methods as regular parameters. A common pattern is to pass a mutable reference to a parcelable so that the service can fill in or modify its fields. This is based on the FillOutStructuredParcelable pattern used in the rsbinder test suite. As with the constants above, StructuredParcelable in the snippets below names the generated module: the struct is written module-qualified as StructuredParcelable::StructuredParcelable, while the bit constants (StructuredParcelable::BIT0, StructuredParcelable::BIT2) resolve through the module.
Service implementation:
#![allow(unused)] fn main() { fn FillOutStructuredParcelable( &self, parcelable: &mut StructuredParcelable::StructuredParcelable, ) -> rsbinder::BinderResult<()> { parcelable.shouldBeJerry = "Jerry".into(); parcelable.shouldContainThreeFs = vec![parcelable.f, parcelable.f, parcelable.f]; parcelable.shouldSetBit0AndBit2 = StructuredParcelable::BIT0 | StructuredParcelable::BIT2; Ok(()) } }
Client side:
#![allow(unused)] fn main() { let mut parcelable = StructuredParcelable::StructuredParcelable { f: 17, shouldSetBit0AndBit2: 0, ..Default::default() }; service.FillOutStructuredParcelable(&mut parcelable)?; assert_eq!(parcelable.shouldBeJerry, "Jerry"); assert_eq!(parcelable.shouldContainThreeFs, vec![17, 17, 17]); assert_eq!( parcelable.shouldSetBit0AndBit2, StructuredParcelable::BIT0 | StructuredParcelable::BIT2 ); }
The service receives the parcelable by mutable reference, reads existing field values, and populates the remaining fields before returning. The client can then inspect the modified parcelable.
Nullable Parcelable
The @nullable annotation allows a parcelable parameter or return type to be None. In the generated Rust code, nullable parcelable types are represented as Option<T>.
AIDL declaration:
@nullable Empty RepeatNullableParcelable(in @nullable Empty input);
Service implementation:
#![allow(unused)] fn main() { fn RepeatNullableParcelable( &self, input: Option<&Empty>, ) -> rsbinder::BinderResult<Option<Empty>> { Ok(input.cloned()) } }
When the client passes None, the service receives None and can return None. When a value is provided, standard Option methods like cloned(), map(), and as_ref() work as expected. Note that cloned() requires the parcelable type to derive Clone via @RustDerive(Clone=true).
Recursive Structures
AIDL supports self-referential parcelable types. Mark the recursive field @nullable(heap=true): the inner value then lives on the heap, so the struct has a finite, known size. As in AOSP's Rust backend, such a field is Option<Box<T>> wherever it is, on a cycle or not. heap=true is accepted only on a parcelable or union type; on any other type (a String, an array, an interface) it is rejected, as AOSP rejects it.
Every cycle needs at least one such field. Once one field on the cycle is boxed, the other fields on it stay inline, as in AOSP: in parcelable A { @nullable(heap=true) B b; } parcelable B { A a; }, A.b is Option<Box<B>> and B.a is a plain A. A type reached through an interface handle or a Vec element keeps the enclosing type finite and closes no cycle. A cycle made only of non-@nullable fields or fixed-size arrays (T[N], which store their elements inline) is rejected with aidl::recursive_parcelable, because neither form can be boxed.
rsbinder also accepts a bare @nullable field (no heap=true) that closes a cycle and boxes it, since Rust can represent it. AOSP's aidl rejects that .aidl as a recursive parcelable, so rsbinder-aidl prints a cargo:warning naming the field; add heap=true to keep the file buildable in an Android tree.
AIDL definition (from RecursiveList.aidl in the test suite):
parcelable RecursiveList {
int value;
@nullable(heap=true) RecursiveList next;
}
This generates a Rust struct where next has the type Option<Box<RecursiveList>>: @nullable makes it Option, and heap=true adds the Box.
Rust usage (based on the test_reverse_recursive_list test):
#![allow(unused)] fn main() { // Build a linked list: [9, 8, 7, ..., 0] let mut head = None; for n in 0..10 { let node = RecursiveList { value: n, next: head, }; head = Some(Box::new(node)); } // Send to service for reversal let result = service.ReverseList(head.as_ref().unwrap())?; // Traverse the reversed list: [0, 1, ..., 9] let mut current: Option<&RecursiveList> = Some(&result); for n in 0..10 { assert_eq!(current.map(|inner| inner.value), Some(n)); current = current.unwrap().next.as_ref().map(|n| n.as_ref()); } assert!(current.is_none()); }
Without the Box indirection the Rust compiler would reject the type definition because RecursiveList would need to contain itself directly, leading to an infinite-size type.
ExtendableParcelable and ParcelableHolder
ExtendableParcelable is a pattern that uses ParcelableHolder to support type-safe, extensible data. A ParcelableHolder field can hold any parcelable type, allowing you to extend a parcelable without changing its base definition. This is useful for versioned interfaces where new fields may be added in the future.
AIDL definitions:
parcelable ExtendableParcelable {
int a;
@utf8InCpp String b;
ParcelableHolder ext;
long c;
ParcelableHolder ext2;
}
parcelable MyExt {
int a;
@utf8InCpp String b;
}
Setting an extension (based on the test_repeat_extendable_parcelable test):
#![allow(unused)] fn main() { use std::sync::Arc; let ext = Arc::new(MyExt { a: 42, b: "EXT".into(), }); let mut ep = ExtendableParcelable { a: 1, b: "a".into(), c: 42, ..Default::default() }; ep.ext.set_parcelable(Arc::clone(&ext)) .expect("error setting parcelable"); }
Sending through a service and retrieving the extension:
#![allow(unused)] fn main() { let mut ep2 = ExtendableParcelable::default(); service.RepeatExtendableParcelable(&ep, &mut ep2)?; assert_eq!(ep2.a, ep.a); assert_eq!(ep2.b, ep.b); assert_eq!(ep2.c, ep.c); let ret_ext = ep2.ext.get_parcelable::<MyExt>() .expect("error getting parcelable"); assert!(ret_ext.is_some()); let ret_ext = ret_ext.unwrap(); assert_eq!(ret_ext.a, 42); assert_eq!(ret_ext.b, "EXT"); }
Key points about ParcelableHolder:
- Type erasure: The
ParcelableHolderstores the extension in a type-erased manner. You must specify the concrete type when callingget_parcelable::<T>(). - Arc wrapping: Extensions are set using
Arc<T>, which allows shared ownership of the extension data. - Multiple holders: A single parcelable can have multiple
ParcelableHolderfields (as shown withextandext2above), each holding a different extension type. - Versioning: This mechanism is particularly useful for forward compatibility. Older code that does not know about newer extension types can still deserialize the base parcelable and pass the
ParcelableHolderthrough without losing data. - Passing through over RPC: an undecoded holder can be passed on over kernel binder, and within one RPC session on the android-16 (v2) wire. On the r34 and android-13 v0/v1 wires, and into a different session or transport, writing it returns
BadType; decode it withget_parcelable::<T>()first. TheParcelableHolderrustdoc lists each case.
Generic Parcelables
A parcelable may take type parameters, as AOSP's
android.hardware.common.fmq.MQDescriptor<T, Flavor> does:
parcelable MQDescriptor<@FixedSize T, Flavor> {
GrantorDescriptor[] grantors;
NativeHandle handle;
int quantum;
int flags;
}
A parameter never reaches the parcel. It is a compile-time label that ties the descriptor to an element type and a flavor, so two instantiations produce the same bytes. The generated struct mirrors AOSP's Rust backend:
#![allow(unused)] fn main() { pub struct MQDescriptor<T, Flavor> { pub grantors: Vec<GrantorDescriptor>, pub handle: NativeHandle, pub quantum: i32, pub flags: i32, pub _phantom_T: core::marker::PhantomData<T>, pub _phantom_Flavor: core::marker::PhantomData<Flavor>, } }
Every impl (Default, Parcelable, Serialize, Deserialize,
ParcelableMetadata) is generic with no bound on the parameters. The phantom
fields are pub (AOSP keeps them private) so MQDescriptor { quantum: 4, ..Default::default() } compiles outside the generated module. Note that
#[derive(Debug)] and @RustDerive bound the parameters the way any derive
does: Foo<Bar>: PartialEq needs Bar: PartialEq.
An annotation on a parameter is a requirement on the argument, checked at
every use site: @FixedSize T accepts a primitive, an enum, or a @FixedSize
parcelable or union (MQDescriptor<byte, …> compiles, MQDescriptor<String, …>
does not; a generic instantiation such as Elem<int> never qualifies, as in
AOSP), and @VintfStability T accepts only a @VintfStability declaration.
Any other annotation on a parameter is a requirement no argument meets: the
Java-only @JavaPassthrough and @JavaSuppressLint parse on the declaration,
as in AOSP, but every use of such a generic is rejected. The argument count
must match the declaration.
The generator rejects, with a diagnostic naming the reason:
- a field whose type is a parameter (
parcelable Foo<T> { T value; }) — nothing would be written for it; - a generic
union— an enum with an unused parameter has no Rust form; - a nested declaration inside a generic parcelable — AOSP's rule ("Generic types can't have nested types"); a field could only reach it by a path the parameter's name would capture;
- a parameter named like something the generated Rust spells unqualified: a
keyword,
String,Vec,Option,Box,Default,core,std, a primitive, thersbindercrate, or the parcelable itself (parcelable R<R>). A nested type namedVec,Box,Option,String,Default,stdorrsbinderis not refused and breaks the generated file instead (see What the compiler rejects); - an array, a
Listorvoidas a type argument; - an annotation on a type argument (
Q<@nullable Elem>) — as in AOSP,@nullablebelongs to the whole field:@nullable Q<Elem>.
Tips
-
Always use
@RustDerive(Clone=true)if you need to clone parcelable values. This is required for patterns likeinput.cloned()with nullable parameters. Only add it when all fields in the parcelable actually implementClone. -
Use
@RustDerive(PartialEq=true)when you need to compare parcelable instances in assertions or business logic. As withClone, all fields must implementPartialEq. -
Write
@nullable(heap=true)on a recursive field. It gives the field the sameOption<Box<T>>type AOSP's Rust backend generates, and keeps the.aidlaccepted by AOSP'saidl; a bare@nullablethere works in rsbinder but draws a warning. -
Default values in AIDL translate to Rust's
Defaulttrait. When you writeint count = 5;in AIDL, callingMyParcelable::default()in Rust will produce a struct withcountset to5. -
Use
..Default::default()for partial initialization. When constructing a parcelable where you only need to set a few fields, use Rust's struct update syntax to fill the rest with defaults:#![allow(unused)] fn main() { let ep = ExtendableParcelable { a: 1, b: "hello".into(), ..Default::default() }; } -
ParcelableHolder extensions are type-erased. Always use
get_parcelable::<T>()with the correct concrete type to extract the extension. If the wrong type is specified, the deserialization will fail. -
Place each parcelable in its own
.aidlfile. Following the AIDL convention, each parcelable type should be defined in a separate file whose name matches the type name (e.g.,UserProfile.aidlforparcelable UserProfile). -
Constants live on the generated module, not the struct. Import the module:
MyParcelable::MAX_VALUEis the constant andMyParcelable::MyParcelableis the struct. Importing the struct directly hides the constants.
Enum and Union
Beyond interfaces and parcelables, AIDL has enums — named integer constants — and unions — a value that is one of several types at a time. Both map onto Rust, though the enum mapping is not the one you would guess.
Enum Types
AIDL enums are backed by a specific integer type, declared using the @Backing annotation. Unlike Rust's native enums, AIDL enums map to newtype structs wrapping the backing integer. This design preserves wire compatibility and allows values outside the defined set, which is important for forward compatibility in IPC.
Defining Enums in AIDL
The @Backing(type=...) annotation specifies the underlying integer type. It is optional — when omitted, the backing type defaults to byte. Here are examples for each supported backing type:
Byte-backed enum:
@Backing(type="byte")
enum ByteEnum {
FOO = 1,
BAR = 2,
BAZ,
}
Int-backed enum:
@Backing(type="int")
enum IntEnum {
FOO = 1000,
BAR = 2000,
BAZ,
}
Long-backed enum:
@Backing(type="long")
enum LongEnum {
FOO = 100000000000,
BAR = 200000000000,
BAZ,
}
When a value is omitted (as with BAZ above), it is automatically assigned the previous value plus one. So BAZ would be 3, 2001, and 200000000001 respectively.
Backing Type Mapping
The AIDL backing type determines the Rust integer type used inside the generated newtype struct:
| AIDL Backing Type | Rust Type |
|---|---|
byte | i8 |
int | i32 |
long | i64 |
Using Enums in Rust
Enum values are accessed as associated constants on the generated struct. The generated type derives Debug, Default, Copy, Clone, PartialOrd, Ord, PartialEq, Eq, and Hash, plus serialization traits automatically.
#![allow(unused)] fn main() { // Access enum values as associated constants let e = ByteEnum::FOO; let result = service.RepeatByteEnum(e)?; assert_eq!(result, ByteEnum::FOO); }
Enums work naturally with arrays and vectors:
#![allow(unused)] fn main() { // Enums can be used in arrays let input = [ByteEnum::FOO, ByteEnum::BAR, ByteEnum::BAZ]; let mut repeated = vec![]; let reversed = service.ReverseByteEnum(&input, &mut repeated)?; }
Each generated enum type provides an enum_values() method that returns a fixed-size array ([Self; N]) of all defined values, which is useful for iteration and validation:
#![allow(unused)] fn main() { // enum_values() returns all defined values let all_values = ByteEnum::enum_values(); }
Enums in Service Interfaces
Enums are commonly used as parameters and return types in AIDL interfaces:
interface ITestService {
ByteEnum RepeatByteEnum(ByteEnum token);
IntEnum RepeatIntEnum(IntEnum token);
LongEnum RepeatLongEnum(LongEnum token);
ByteEnum[] ReverseByteEnum(in ByteEnum[] input, out ByteEnum[] repeated);
}
The generated Rust trait methods use the enum types directly, providing compile-time type safety across the IPC boundary.
Union Types
AIDL unions represent a tagged value that holds exactly one of several possible fields at a time. They map to Rust enum types, which are a natural fit since Rust enums are sum types with variants.
Defining Unions in AIDL
Here is a union definition from the rsbinder test suite:
@RustDerive(Clone=true, PartialEq=true)
union Union {
int[] ns = {};
int n;
int m;
@utf8InCpp String s;
@nullable IBinder ibinder;
@utf8InCpp List<String> ss;
ByteEnum be;
const @utf8InCpp String S1 = "a string constant in union";
}
Key points about union definitions:
- The first field is the default. When a union is default-constructed, it takes the value of the first field. In this example, the default is
nsinitialized to an empty array{}. A first field without an initializer takes its type's RustDefault, as in AOSP's Rust backend; for an enum that is the backing value0, not the first enumerator. - Fields can have different types, including primitives, strings, arrays, other AIDL types, and even binder references.
- Constants can be defined inside unions, independent of the union's variants.
@RustDeriveis recommended so the generated Rust type supportsCloneandPartialEq.
Using Unions in Rust
The AIDL union generates a Rust enum. Because AIDL types are organized into modules, the union type and its enum variants live inside a module named after the union. Variants are accessed as Union::Union::VariantName(...). A variant's name is its field's name with only the first letter uppercased, as in AOSP's Rust backend: ns becomes Ns, nullable_iface becomes Nullable_iface, and URL stays URL. Two fields whose names differ only in the first letter's case (foo and Foo) are therefore rejected, as AOSP rejects them.
#![allow(unused)] fn main() { // Default value is the first field assert_eq!(Union::Union::default(), Union::Union::Ns(vec![])); // Creating union variants let u1 = Union::Union::N(42); let u2 = Union::Union::S("hello".into()); let u3 = Union::Union::Be(ByteEnum::FOO); }
Constants defined inside a union are accessed directly on the module, not through a variant:
#![allow(unused)] fn main() { // Constants defined in the union let s = Union::S1; // "a string constant in union" // Using a constant as a union variant value let u = Union::Union::S(Union::S1.to_string()); }
Union Tags
Each union has an associated Tag type that identifies which variant is currently active. (Like AIDL enums, it is generated as a newtype struct whose variants are exposed as associated constants such as Union::Tag::n. It is backed by i32, or by i8 in a @FixedSize union, as in AOSP.) Tags are useful when you need to inspect or communicate which field a union holds without extracting the value itself.
#![allow(unused)] fn main() { let result = service.GetUnionTags(&[ Union::Union::N(0), Union::Union::Ns(vec![]), ])?; assert_eq!(result, vec![Union::Tag::n, Union::Tag::ns]); }
Tags can also be used in match expressions when implementing service logic. Here is an example from the test service implementation:
#![allow(unused)] fn main() { fn GetUnionTags( &self, input: &[Union::Union], ) -> Result<Vec<Union::Tag>> { Ok(input.iter().map(|u| match u { Union::Union::Ns(_) => Union::Tag::ns, Union::Union::N(_) => Union::Tag::n, Union::Union::M(_) => Union::Tag::m, Union::Union::S(_) => Union::Tag::s, Union::Union::Ibinder(_) => Union::Tag::ibinder, Union::Union::Ss(_) => Union::Tag::ss, Union::Union::Be(_) => Union::Tag::be, }).collect()) } }
Unions Containing Enums (EnumUnion)
Unions can contain enum types as fields and specify default values using enum constants:
@RustDerive(Clone=true, PartialEq=true)
union EnumUnion {
IntEnum intEnum = IntEnum.FOO;
LongEnum longEnum;
/** @deprecated do not use this */
int deprecatedField;
}
In this example, the default value is the first field (intEnum) initialized to IntEnum.FOO. In Rust:
#![allow(unused)] fn main() { assert_eq!(EnumUnion::default(), EnumUnion::IntEnum(IntEnum::FOO)); }
A /** @deprecated note */ block on a field becomes #[deprecated = "note"] on the generated variant (#[deprecated] when there is no note). The generated module allows the lint for its own plumbing, so only your code's uses warn — which fails a -D warnings build until you migrate or allow it.
Nested Unions
Unions can also be nested inside other unions:
union UnionInUnion {
EnumUnion first;
int second;
}
This allows building complex tagged-value hierarchies that are fully type-safe on the Rust side.
Tips
- Specify
@Backingexplicitly. It is optional — the default isbyte— but stating it makes the wire format unambiguous to a reader. - Handle values you never declared. An AIDL enum is an integer on the wire, so a peer built against a newer definition can send one the current enum does not name. That is the point of the newtype mapping; do not assume the set is closed.
- Tags keep the AIDL field names, so they are lowercase:
Union::Tag::ns, notUnion::Tag::Ns.
Further Reading
- Android AIDL documentation -- the upstream reference for AIDL syntax and semantics
- AIDL annotation reference -- details on
@Backing,@RustDerive, and other annotations - The rsbinder test suite at
tests/aidl/andtests/src/test_client.rscontains comprehensive examples of enum and union usage
AIDL Annotations
Annotations modify what the code generator emits from an .aidl file — trait derivation, backing types, nullability, interface stability. Some that AOSP enforces are recognized here but not checked; each section below says which.
If you have not read AIDL Data Types yet, start there: annotations adjust those mappings rather than replacing them.
@RustDerive
The @RustDerive annotation tells the code generator to add Rust derive attributes to parcelable and union types. Without this annotation, generated types receive only the minimum set of derives needed for serialization.
@RustDerive(Clone=true, PartialEq=true)
parcelable Point {
int x;
int y;
}
This generates a Rust struct with both Clone and PartialEq derived:
#![allow(unused)] fn main() { #[derive(Debug, Clone, PartialEq)] pub struct Point { pub x: i32, pub y: i32, } }
Available Derives
| Derive | Description |
|---|---|
Clone | Enables cloning of the type |
PartialEq | Enables equality comparison |
Copy | Enables bitwise copy (fixed-size types only) |
These are the most common choices, but the list is not exhaustive: as in AOSP, the generator passes through any Name=true identifier as a derive, so other derivable traits (Eq, Hash, PartialOrd, Ord, ...) work the same way.
Why Clone Is Not Derived by Default
Generated types do not derive Clone by default. This is intentional because some AIDL types contain fields that cannot be cloned:
ParcelFileDescriptorwraps anOwnedFd, which represents sole ownership of a file descriptor. Cloning it would require duplicating the file descriptor at the OS level, which is not a simple bitwise copy.ParcelableHoldercontains aMutex, which cannot be cloned.
If your parcelable contains only primitive fields and cloneable types, add @RustDerive(Clone=true) explicitly. If the type contains a ParcelFileDescriptor or ParcelableHolder field, attempting to derive Clone will produce a compile error.
Copy Derivation for Fixed-Size Types
For parcelables that contain only primitive fields, you can derive Copy in addition to Clone. This is typically combined with the @FixedSize annotation:
@RustDerive(Clone=true, Copy=true, PartialEq=true)
@FixedSize
parcelable IntParcelable {
int value;
}
The Copy derive is only valid when all fields are Copy types. Using it on a parcelable that contains String, arrays, or other heap-allocated types will result in a compile error.
@Backing
The @Backing annotation specifies the underlying integer type for an AIDL enum. This controls both the wire format and the generated Rust type.
@Backing(type="byte")
enum Priority {
LOW = 0,
MEDIUM = 1,
HIGH = 2,
}
The generated Rust code uses a newtype struct wrapping the corresponding integer type, with the enum values exposed as associated constants:
#![allow(unused)] fn main() { pub mod Priority { #![allow(non_upper_case_globals)] // Generated by rsbinder::declare_binder_enum! — produces a // newtype struct, not a type alias, so values stay type-safe // and the enum carries its full set of derived traits. pub struct Priority(pub i8); impl Priority { pub const LOW: Self = Self(0); pub const MEDIUM: Self = Self(1); pub const HIGH: Self = Self(2); } } }
The generated type derives Debug, Default, Copy, Clone,
PartialOrd, Ord, PartialEq, Eq, and Hash, and provides an
enum_values() associated function returning a [Self; N] of all
defined variants. See Enum and Union for
the full code-generation details.
Supported Backing Types
| AIDL Backing | Rust Type | Size |
|---|---|---|
"byte" | i8 | 1 byte |
"int" | i32 | 4 bytes |
"long" | i64 | 8 bytes |
If no @Backing annotation is specified, the default backing type is "byte".
Choose the smallest backing type that fits your range of values. Enum values are serialized to the Binder transaction as their backing integer type, so a smaller backing type produces a more compact wire format.
@nullable
The @nullable annotation marks a type as optional, indicating that the value may be absent. In Rust, this maps to Option<T>.
Basic Usage
Apply @nullable to method parameters, return values, or struct fields:
interface IUserService {
@nullable String getName();
void setValues(in @nullable int[] values);
}
The generated Rust signatures use Option:
#![allow(unused)] fn main() { fn getName(&self) -> rsbinder::BinderResult<Option<String>>; fn setValues(&self, values: Option<&[i32]>) -> rsbinder::BinderResult<()>; }
When None is passed over a Binder transaction, a null marker is written to the parcel. The receiving side deserializes it as None without allocating any data.
Heap-Allocated Nullable: @nullable(heap=true)
AOSP AIDL uses @nullable(heap=true) to mark fields of recursive or
self-referential parcelables, so that the C++ and Java backends know to
allocate the inner value on the heap:
parcelable RecursiveList {
int value;
@nullable(heap=true) RecursiveList next;
}
The Rust backend generates:
#![allow(unused)] fn main() { pub struct RecursiveList { pub value: i32, pub next: Option<Box<RecursiveList>>, } }
The Box indirection is necessary because without it, RecursiveList would contain itself directly, making the type infinitely large.
As in AOSP's Rust backend, a heap=true field is Option<Box<T>> wherever it
is, on a cycle or not, and a method argument or return keeps its usual type.
heap=true is accepted only on a parcelable or union type, and heap is the
annotation's only parameter; anything else is an error, as in AOSP.
rsbinder also boxes a bare @nullable field that closes a reference cycle — a
cycle of any length, not just a direct self-reference — because Rust can
represent it. A path through an interface handle or a Vec element already
keeps the type finite and closes no cycle. AOSP's aidl rejects a bare
@nullable on a cycle, so rsbinder-aidl prints a cargo:warning for it; write
heap=true if the same AIDL must also compile under the AOSP toolchain.
For non-recursive optional fields, plain @nullable is sufficient and avoids the extra heap allocation.
@utf8InCpp
The @utf8InCpp annotation exists in Android AIDL to specify UTF-8 encoding for strings in the C++ backend, where the default encoding is UTF-16. In rsbinder, this annotation has no effect because Rust strings are always UTF-8.
interface ITextService {
@utf8InCpp String getData();
@utf8InCpp List<String> getNames();
}
Both String and @utf8InCpp String produce identical Rust type mappings:
| Direction | Rust Type |
|---|---|
Input (in) | &str |
| Output / Return | String |
You may encounter this annotation in AIDL files that were originally written for Android's C++ backend. It is safe to keep or remove it when targeting rsbinder; the generated Rust code is identical either way.
@Descriptor
The @Descriptor annotation overrides the interface descriptor string that identifies an interface on the Binder wire protocol. This is useful when renaming an interface while maintaining backward compatibility with existing clients or services.
Every Binder interface has a descriptor string derived from its fully qualified name (e.g., android.aidl.tests.IOldName). When you rename an interface, the descriptor changes, breaking compatibility. The @Descriptor annotation lets you decouple the source name from the wire descriptor.
Consider an interface that was originally named IOldName:
// IOldName.aidl
interface IOldName {
String RealName();
}
You can create a new interface INewName that uses the same descriptor:
// INewName.aidl
@Descriptor(value="android.aidl.tests.IOldName")
interface INewName {
String RealName();
}
Because both interfaces share the same descriptor, they are interchangeable at the Binder level:
#![allow(unused)] fn main() { // A service registered as IOldName can be used as INewName let new_from_old = old_service .as_binder() .into_interface::<dyn INewName::INewName>(); assert!(new_from_old.is_ok()); }
This is particularly useful during interface migrations where you want to rename types in your codebase without requiring all clients and services to update simultaneously.
@VintfStability
The @VintfStability annotation marks a type or interface as part of the Vendor Interface (VINTF). VINTF-stable types are subject to stricter compatibility rules to ensure that vendor and system partitions can be updated independently.
@VintfStability
parcelable VintfData {
int value;
}
Stability Rules
In the upstream Android toolchain, VINTF-stable types are meant to contain only other VINTF-stable types so that their serialization format stays stable across independent system/vendor updates — critical for framework↔HAL compatibility.
rsbinder's generator stamps the generated type or interface with Stability::Vintf and enforces the rule at compile time: a @VintfStability declaration that references a type which is not VINTF-stable is an error. AOSP enforces the same rule through aidl_interface { stability: "vintf" }; rsbinder has no such build mode and checks the references instead, which rejects the same contracts. A @RustOnlyStableParcelable is exempt, as AOSP exempts a stable-API parcelable, and a non-VINTF type may still use a VINTF one.
A declaration nested inside a @VintfStability type is VINTF-stable too, matching AOSP's scoped lookup. At run time, ParcelableHolder::set_parcelable on a VINTF holder returns BadValue for a payload whose stability does not include VINTF.
@FixedSize
The @FixedSize annotation indicates that a parcelable has a fixed serialization size, meaning its wire format is always the same number of bytes regardless of the field values.
@FixedSize
parcelable FixedPoint {
int x;
int y;
}
Constraints
A @FixedSize parcelable or union may only contain:
- Primitive types (
boolean,byte,char,int,long,float,double) - Enums
- Other
@FixedSizeparcelables and unions - Fixed-size arrays (
int[4]) of the above
and may not contain String, IBinder, ParcelFileDescriptor, ParcelableHolder, an interface, a variable-length array (T[]), a List<T>, or a @nullable type. @FixedSize is not inherited by nested declarations.
rsbinder-aidl rejects a field that breaks these rules, porting AOSP's CanBeFixedSize. The annotation changes the generated code in one place: a @FixedSize union's implicit Tag is backed by byte (Tag(pub i8)) instead of int, as AOSP's UnionTagGenerater declares it, so a Tag[] goes on the wire as byte[], and such a union may have at most 128 fields. The layout and wire format of every field are otherwise unchanged.
Relationship with @RustDerive(Copy=true)
Copy is derived purely from @RustDerive(Copy=true); @FixedSize is not a prerequisite for it in rsbinder. Conceptually you should still only add Copy to types with a fixed layout, so pairing the two documents intent:
@RustDerive(Clone=true, Copy=true, PartialEq=true)
@FixedSize
parcelable Coordinate {
double latitude;
double longitude;
}
@EnforcePermission
@EnforcePermission declares the Android permission(s) a method requires.
The generated on_transact arm checks the caller against
PermissionManagerService before your handler runs and returns
EX_SECURITY if the check fails — so the handler body needs no
authorization code:
interface IExample {
@EnforcePermission("android.permission.INTERNET")
void doNetworking();
@EnforcePermission(allOf = {"android.permission.A", "android.permission.B"})
void needsBoth();
@EnforcePermission(anyOf = {"android.permission.A", "android.permission.B"})
void needsEither();
}
The companion documentation-only annotations @PermissionManuallyEnforced
and @RequiresNoPermission are recognized and emit no runtime check (they
exist so every method can declare its permission posture).
Kernel-only. Android permissions are a kernel-binder concept. Over the RPC transport an
@EnforcePermissionmethod is denied (EX_SECURITY) unconditionally — granting would mean granting root to an anonymous peer. For RPC authorization use the transport-native mechanisms, or inject aPermissionAuthorityto back the check with your own policy. See Security & Authorization.
Summary
| Annotation | Applies To | Rust Effect |
|---|---|---|
@RustDerive | parcelable, union | Adds derive attributes (Clone, Copy, PartialEq) |
@Backing | enum | Sets the backing integer type (i8, i32, i64) |
@nullable | field, param, return | Maps to Option<T> |
@nullable(heap=true) | field | heap=true is ignored; rsbinder boxes a field its own cycle analysis finds recursive |
@utf8InCpp | String | No effect in Rust (strings are always UTF-8) |
@Descriptor | interface | Overrides the wire descriptor string |
@VintfStability | parcelable, interface, type parameter | Stamps Stability::Vintf; every referenced type must be VINTF-stable (checked). On a type parameter (Foo<@VintfStability T>): the argument must be a @VintfStability declaration |
@FixedSize | parcelable, union, type parameter | Fields checked to be fixed-size; no field layout change, but a union's Tag is i8-backed (Copy comes from @RustDerive(Copy=true)). On a type parameter (MQDescriptor<@FixedSize T, Flavor>): the argument must itself be fixed-size |
@deprecated (javadoc) | any declaration, method, field, constant, enumerator | Emits #[deprecated] / #[deprecated = "note"] from the first /** … @deprecated … */ among the comments before the item (AOSP FindDeprecated, android-14.0.0_r50+). A comment written between the item's annotations, type and name is not read |
@EnforcePermission | interface method | Generates a PermissionManagerService check (kernel-only; denied over RPC) |
When writing AIDL files for rsbinder, the most commonly used annotations are @RustDerive (for ergonomic Rust types), @Backing (for enums), and @nullable (for optional values). The remaining annotations are important for interoperability with Android or for specific use cases like recursive types and interface migration.
Interface Macros (no .aidl)
#[rsbinder::interface] declares a Binder interface as a Rust trait — no
.aidl file, no build.rs, no generated-code directory:
rsbinder = { version = "0.12", features = ["macros"] }
#![allow(unused)] fn main() { use rsbinder::{interface, BinderResult, Strong, Uri}; #[interface(descriptor = "com.example.IHello")] pub trait IHello { fn echo(&self, msg: &str) -> BinderResult<String>; fn fill(&self, values: &mut Vec<i32>) -> BinderResult<()>; // `&mut` = out #[oneway] fn ping(&self) -> BinderResult<()>; } // `Hello` is your `impl IHello`, plus `impl Interface for Hello {}`. rsbinder::serve(Uri::kernel())?.add("hello", BnHello::new_binder(Hello))?.run()?; let hello: Strong<dyn IHello> = rsbinder::connect(Uri::kernel().with_service("hello"))?; }
BnHello, BpHello and IHelloDefault appear where you wrote the trait, so
the service impl, the registration and the call sites read exactly as they do
on the .aidl path.
The macro is not a second code generator: it fills the same
rsbinder_aidl::render structs the AIDL front-end fills and runs the same
templates. A trait here and the equivalent .aidl produce byte-for-byte
identical code, enforced by golden tests. Moving between the two never
touches a call site and never changes a byte on the wire.
The feature is off by default, because it pulls the AIDL compiler in as a proc-macro dependency — a build-time cost that a crate consuming generated code has no reason to carry.
Which path to use
| Use | When |
|---|---|
#[rsbinder::interface] | rsbinder on both ends, wire private to both |
.aidl | An Android service or client, another language on the other end, or a published interface |
.aidl is also the only path that carries unions, interface constants,
@VintfStability, @EnforcePermission, ParcelableHolder fields, generics
and nested types. The macro has no syntax for those; where a Rust declaration
could be mistaken for one, it is a compile error naming the reason.
What a signature means
| Signature | Meaning |
|---|---|
x: i32 — an AIDL scalar, or a bare name that may be an enum (asserted Copy) | in argument, by value |
x: &Cfg, x: &str, x: &[T], Option<&T> — every other in type borrows | in argument |
x: &mut T | out — the server fills the caller's value |
#[inout] x: &mut T | written and read back |
Option<T> | @nullable |
#[oneway] on a method | no reply; must return BinderResult<()> |
#[deprecated] / #[deprecated = "…"] | AIDL's @deprecated, on the trait, a method, a #[derive(Parcelable)] struct or a field |
The macro accepts only what .aidl renders, spelled the way .aidl renders
it, so moving the interface to .aidl later never changes a call site. A
shape with no .aidl equivalent is a compile error naming the form to use:
- Only AIDL's scalars:
bool,i8,i32,i64,f32,f64,u16, andu8as an array element.u32,u64,i16,usize,u128, Rust'schar, andi8inside an array are refused. - An
inargument other than a scalar or an enum is borrowed:&strrather thanStringor&String,&[T]rather thanVec<T>or&Vec<T>,Option<&T>rather than&Option<T>. - A primitive or a
Stringcannot beout—.aidlpasses them onlyin, and@nullabledoes not change that (&mut Option<String>is refused too). Return the value, or use aVec<T>or a parcelable. - A binder object (
Strong<dyn IFoo>,SIBinder) cannot beoutorinout, and aParcelFileDescriptorcannot beout,Optionor not — AOSPaidlrefuses them. Return the object, pass the fd#[inout], or use an array, which takes every direction (out:&mut Vec<Option<_>>,#[inout]:&mut Vec<_>). - A primitive has no null form, so
Option<i32>is refused; so isOption<Mode>for a derived enum, which travels as itsreprscalar. - A borrowed type nested inside another (
&[&str]) has nothing to borrow from once decoded. Use the owned form. BinderResult<T, E>,()or a trait object as an argument, and a duplicate method or argument name are refused.#[deprecated(since = …)]andnote = …are refused too, because@deprecatedhas nowhere to put them.
The exact spelling for every array and Option combination, in each
direction, is the type table in the
rsbinder-macros docs.
Methods are declared fn, not async fn. The async feature emits the
IFooAsync halves from the same declaration.
Data types
#![allow(unused)] fn main() { use rsbinder::{BinderEnum, Parcelable}; #[derive(Parcelable, Default, Debug, Clone, PartialEq)] #[parcelable(descriptor = "com.example.Config")] pub struct Config { pub name: String, pub retries: i32, pub extra: Option<Vec<u8>>, } #[derive(BinderEnum, Default, Clone, Copy, Debug, PartialEq, Eq)] #[repr(i32)] pub enum Mode { #[default] Fast = 0, Safe = 1, } }
#[derive(Parcelable)] emits only the codec, leaving Default, Debug and
the rest to the usual derives. Default is required: decoding starts from it,
which is how a field an older writer never wrote gets a value. Named fields
only — field order is wire order, so append only, as in .aidl.
#[derive(BinderEnum)] needs #[repr(i8|i32|i64)], the AIDL backing type that
goes on the wire, and an explicit value per variant so the wire value is
visible at the declaration.
A derived enum is closed. An undeclared value decodes as
BadValue, where an.aidlenum carries it through. For a peer that may send a value your build has never heard of, use.aidlordeclare_binder_enum!.
Either type works with to_bytes / from_bytes.
Rules that bite
Reordering methods is a wire break. Transaction codes follow declaration
order (FIRST_CALL_TRANSACTION + i), as in .aidl without explicit codes.
Append at the end.
Set the descriptor for anything shared. It defaults to the bare trait name, which is fine inside one crate and not across two.
Spell out-parameter types directly. A proc macro cannot see through a type
alias, so &mut Ids for type Ids = Vec<i32> reads as an opaque named type
and loses the length word .aidl writes for an out vector. Path qualification
(std::vec::Vec<i32>) is matched structurally and is fine; an alias is not.
A bare path in a signature is your own, super:: is not. The generated
body lands in a module one level below where you wrote the macro and reaches
your scope through use super::*;, so super::X names that module — a
signature meaning the parent has to say crate::X.
Names starting with __Rsb are reserved. The async half declares its type
parameters and a helper struct (__RsbPool, __RsbService, __RsbRuntime,
__RsbAsyncWrapper) in the scope the signatures resolve in. The macro refuses a
trait or a bare signature path starting with __Rsb; reach such a type through
crate::…. .aidl refuses type declaration names with the same prefix. The
same glob import means a mod rsbinder or use … as rsbinder in your module
is what the generated rsbinder:: paths resolve to.
Service Development
Once your interface compiles, the rest is the service that implements it and the client that calls it. This part of the guide collects the runtime patterns you will use day-to-day.
If you have not yet built a working service, start with Hello, World! and come back.
Chapters
- Service Patterns — The three pieces every rsbinder service needs (state struct,
impl Interface,impl IYourService) and how to combine them for single- and multi-interface processes. - Async Service — Implementing services with
async fnon top of the Tokio runtime, and how the async traits differ from their sync counterparts. - Callbacks and Interfaces — Passing
IBinderobjects in either direction, managing callback collections, and watching for remote process death. - ParcelFileDescriptor — Sending pipes, files, and sockets across the Binder boundary as owned file descriptors.
- Shared Memory — Sharing a mapped region instead of copying it, for payloads a transaction should not carry.
- Error Handling — The
StatusCode/Statussplit, exception codes, and how AIDL methods surface both transport-level and application-level failures. - Service Manager (HUB) — Registering, looking up, and waiting for services; differences between Linux (
rsb_hub) and Android's nativeservicemanager; and how it relates to the RPC transport.
Each builds on the one before it, but to ship a working service you only need Service Patterns and Service Manager (HUB); the rest fill in capabilities as you need them.
Service Patterns
How to structure a service, from a single method to a process hosting several.
This chapter uses the low-level lifecycle —
ProcessState, the thread pool,hub::add_service— because that is what the pieces actually are.rsbinder::serve(Uri::kernel())?.add(name, binder)?.run()?performs exactly these steps and is the shorter way to write them; reach past it when you need control aUridoes not express. See Cross-Transport Services.
Basic Service Structure
Every Binder service in rsbinder requires three pieces:
- A struct that holds service state.
- An
impl Interfaceblock for the struct, optionally providing adump()method. - An
impl IYourServiceblock for the struct, implementing the AIDL-defined methods.
#![allow(unused)] fn main() { use rsbinder::*; // 1. Define a struct to hold service state. // Use #[derive(Default)] when you want to construct with ::default(). #[derive(Default)] struct MyService { // Add fields here to maintain service state. // Use Mutex<T> or RwLock<T> for fields that need interior mutability, // since method receivers are &self (shared references). } // 2. Implement the Interface trait (required for all services). // The dump() method is optional but recommended for debugging. impl Interface for MyService { fn dump(&self, writer: &mut dyn std::io::Write, args: &[String]) -> Result<()> { for arg in args { writeln!(writer, "{arg}").unwrap(); } Ok(()) } } // 3. Implement your AIDL-generated interface trait. impl IMyService::IMyService for MyService { fn echo(&self, input: &str) -> rsbinder::BinderResult<String> { Ok(input.to_owned()) } } }
Note that all AIDL method implementations receive &self, not &mut self. If your
service needs mutable state, wrap the relevant fields in std::sync::Mutex or
std::sync::RwLock. The test suite demonstrates this pattern with a HashMap
protected by a Mutex:
#![allow(unused)] fn main() { #[derive(Default)] struct TestService { service_map: Mutex<HashMap<String, rsbinder::Strong<dyn INamedCallback::INamedCallback>>>, } }
Service Registration and Main Loop
A service binary follows a consistent lifecycle: initialize the process state, start the thread pool, create and register services, then block on the thread pool. Here is the standard pattern based on the project's test service implementation:
fn main() -> std::result::Result<(), Box<dyn std::error::Error>> { // Initialize ProcessState. This opens the Binder device and configures // the process for Binder IPC. Must be called before any Binder operations. // `init_default()` returns `Result<&'static ProcessState, ...>`; use `?` // to surface device-open or initialization failures. ProcessState::init_default()?; // Start the binder thread pool and enable kernel-driven thread // spawning, so concurrent transactions are handled in parallel. ProcessState::start_thread_pool(); // Create a Binder object from your service implementation. // BnMyService is the server-side stub generated by the AIDL compiler. let service = BnMyService::new_binder(MyService::default()); // Register the service with the service manager (hub). // The first argument is the service name used by clients to find it. // `add_service` accepts anything convertible into `SIBinder`, so the // typed `Strong<dyn IMyService>` goes in directly — no `.as_binder()`. hub::add_service("com.example.myservice", &service)?; // Block the main thread and process incoming Binder transactions. // This call does not return under normal operation. Ok(ProcessState::join_thread_pool()?) }
Each function in this lifecycle serves a specific purpose:
ProcessState::init_default()opens the Binder device (typically/dev/binderfs/binder) and sets up process-wide state for Binder communication.ProcessState::start_thread_pool()spawns a binder worker thread and enables kernel-driven spawning of further workers. Without this call, transactions are served only by threads that explicitly calljoin_thread_pool(), so the process is effectively single-threaded.BnMyService::new_binder()wraps your implementation struct in a Binder-compatible object. TheBnprefix stands for "Binder native" (the server-side stub).hub::add_service()registers your service with the service manager so that clients can discover it by name.ProcessState::join_thread_pool()makes the calling thread join the Binder thread pool, blocking it to process transactions indefinitely.
Multiple Services in One Process
A single process can host multiple Binder services. The test suite registers four
distinct services in one binary. Each service gets its own struct, its own Interface
and AIDL trait implementations, and its own registration call:
fn main() -> std::result::Result<(), Box<dyn std::error::Error>> { ProcessState::init_default()?; ProcessState::start_thread_pool(); // Register the primary test service. let service = BnTestService::new_binder(TestService::default()); hub::add_service(test_service_name, &service)?; // Register a versioned interface service. let versioned_service = BnFooInterface::new_binder(FooInterface); hub::add_service(versioned_service_name, versioned_service)?; // Register a nested service. let nested_service = INestedService::BnNestedService::new_binder(NestedService); hub::add_service(nested_service_name, nested_service)?; // Register a fixed-size array service. let fixed_size_array_service = IRepeatFixedSizeArray::BnRepeatFixedSizeArray::new_binder(FixedSizeArrayService); hub::add_service(fixed_size_array_service_name, fixed_size_array_service)?; // All services share the same thread pool and process state. Ok(ProcessState::join_thread_pool()?) }
All services in a process share the same ProcessState and thread pool. You only need
to call ProcessState::init_default() and ProcessState::start_thread_pool() once,
regardless of how many services you register.
Implementing dump()
The dump() method is part of the Interface trait and provides a way to inspect
service state at runtime. It is optional -- if you do not override it, the default
implementation does nothing. However, implementing it is valuable for debugging
and diagnostics.
The method receives a writer and a list of string arguments:
#![allow(unused)] fn main() { impl Interface for MyService { fn dump(&self, writer: &mut dyn std::io::Write, args: &[String]) -> Result<()> { for arg in args { writeln!(writer, "{arg}")?; } Ok(()) } } }
An Err from dump() is the reply status the caller receives. A write to a
pipe whose reader has exited fails with EPIPE, which ? converts to
StatusCode::DeadObject; rsbinder sends a handler's DeadObject as
FailedTransaction, because an AOSP BpBinder that receives DEAD_OBJECT
marks the proxy dead and fails every later call on it. The same substitution
applies to every method a service implements.
On the client side, you can invoke dump() on a remote service through its proxy.
The output is written to a file descriptor (typically a pipe):
#![allow(unused)] fn main() { let (read_end, write_end) = rsbinder::ParcelFileDescriptor::pipe()?; let args = vec!["dump".to_owned(), "MyService".to_owned()]; service.as_binder().as_proxy().unwrap().dump(write_end, &args)?; let mut buf = String::new(); (&read_end).read_to_string(&mut buf)?; // buf now contains the dump output }
Testing Service Liveness with ping_binder()
The ping_binder() method tests whether a service is reachable and responsive.
It sends a lightweight ping transaction and returns Ok(()) on success:
#![allow(unused)] fn main() { let hello: Strong<dyn IHello> = rsbinder::connect(Uri::kernel().with_service("my.hello"))?; assert_eq!(hello.as_binder().ping_binder(), Ok(())); }
This is useful for health checks and for verifying that a service is still alive before making more expensive calls.
Default Implementation Pattern
rsbinder supports a default implementation pattern that provides fallback behavior when a method is not implemented by the remote service. This is especially useful for forward compatibility: a client compiled against a newer AIDL interface can still communicate with an older service that does not implement all methods.
To set up a default implementation:
- Define a struct that implements the
Defaulttrait variant of your interface. - Wrap it in an
Arcand register it withsetDefaultImpl. - When the remote service returns
StatusCode::UnknownTransactionfor a method, the default implementation is called instead.
#![allow(unused)] fn main() { // Define a default implementation for methods the server may not support. struct MyDefaultImpl; impl rsbinder::Interface for MyDefaultImpl {} impl IMyServiceDefault for MyDefaultImpl { fn UnimplementedMethod(&self, arg: i32) -> std::result::Result<i32, Status> { // Provide fallback logic. Ok(arg * 2) } } // Register the default implementation globally for this interface. let di: IMyServiceDefaultRef = Arc::new(MyDefaultImpl); <BpMyService as IMyService::IMyService>::setDefaultImpl(di); // When the remote service does not implement UnimplementedMethod, // the default implementation is used transparently. let result = service.UnimplementedMethod(100); assert_eq!(result, Ok(200)); }
Note that setDefaultImpl is a static method on the proxy type (BpMyService). Once
registered, the default implementation applies to all proxies of that interface within
the process.
Client-Side Patterns
Getting a Service Proxy
Clients obtain a typed proxy to a remote service through the hub module:
fn main() -> std::result::Result<(), Box<dyn std::error::Error>> { // ProcessState must be initialized before any Binder operations. ProcessState::init_default()?; // Obtain a strongly-typed proxy for the service. // hub::wait_for_interface blocks until the service is registered and // returns a Strong<dyn IMyService> on success. Use hub::check_interface // instead for a non-blocking probe that fails immediately with // NameNotFound when the service is absent. let service: rsbinder::Strong<dyn IMyService::IMyService> = hub::wait_for_interface("com.example.myservice")?; // Call service methods through the proxy. let result = service.echo("hello")?; println!("Got: {result}"); Ok(()) }
A purely-outbound client like this one needs no binder thread pool. As soon
as the client receives inbound calls — a callback object it passed to the
service, a service-registration notification, or a death recipient — it must
also call ProcessState::start_thread_pool() (see below).
Listing Available Services
You can discover all registered services through the service manager:
#![allow(unused)] fn main() { for name in hub::list_services(hub::DUMP_FLAG_PRIORITY_DEFAULT) { println!("{name}"); } }
Service Notifications
Clients can register a callback to be notified when a service is registered:
#![allow(unused)] fn main() { struct MyServiceCallback; impl Interface for MyServiceCallback {} impl hub::IServiceCallback for MyServiceCallback { fn onRegistration(&self, name: &str, _service: &SIBinder) -> rsbinder::BinderResult<()> { println!("Service registered: {name}"); Ok(()) } } let callback = hub::BnServiceCallback::new_binder(MyServiceCallback); hub::register_for_notifications(SERVICE_NAME, &callback)?; }
onRegistration is an inbound transaction, so it needs the thread pool
running in the client.
Death Recipients
Clients can monitor whether a remote service process is still alive by registering
a death recipient. The binder_died callback is invoked if the service process
terminates:
#![allow(unused)] fn main() { struct MyDeathRecipient; impl DeathRecipient for MyDeathRecipient { fn binder_died(&self, _who: &WIBinder) { println!("The remote service has died."); } } let recipient = Arc::new(MyDeathRecipient); service.as_binder().link_to_death( Arc::downgrade(&(recipient as Arc<dyn DeathRecipient>)) )?; }
To stop receiving notifications, call unlink_to_death with the same weak reference.
binder_died arrives as an inbound binder command, so it too needs the thread
pool running in the client.
Cancelling a Long Operation
Binder has no per-call deadline and no way to abort a transaction in flight,
so a long call is stopped the way the Android framework stops one
(android.os.ICancellationSignal): the service creates a signal, returns
its transport binder to the caller, and the caller sends a oneway cancel()
to that binder. Only the service's own loop can act on it.
#![allow(unused)] fn main() { use rsbinder::cancel::{CancellationSignal, ICancellationSignal}; fn start_export(&self, path: &str) -> rsbinder::BinderResult<Strong<dyn ICancellationSignal>> { let signal = CancellationSignal::new(); let transport = signal.create_transport(); let token = signal.token(); // the work keeps this std::thread::spawn(move || { for chunk in 0..10_000 { if token.is_canceled() { break; } // ... one chunk of work } }); Ok(transport) } }
The caller cancels through the binder it got back. A typed proxy calls
transport.cancel()? directly. A method that returns a bare IBinder hands
the caller an SIBinder, which cancel_remote takes without compiling the
interface a second time:
#![allow(unused)] fn main() { rsbinder::cancel::cancel_remote(&transport)?; }
Points worth knowing before you build on it:
- The service decides what cancellation means. This carries the
notification only; returning a service-specific error saying "cancelled" is
the service's call (AOSP likewise throws
OperationCanceledExceptionfrom the service). - Delivery is best-effort and unordered against your other calls. The
transport is a different binder object than the service, and the kernel does
not order a
onewayto one against a twoway to another: a caller that cancels and then immediately asks the service what it saw can be answered first. Poll, or report through the result of the operation itself. - Only this direction. A caller creating the signal and the service polling it would make every poll a transaction back to the caller — and on the RPC stack it would need the client to have opened incoming connections.
- Declaring it in
.aidl: returningandroid.os.ICancellationSignalmeans your crate compiles its own copy of that interface, which is a different Rust type fromrsbinder::cancel::ICancellationSignal(the wire is the same). Returning a bareIBinderand cancelling it withcancel_remoteavoids the second copy. async:signal.token().canceled().await(thetokiofeature) waits instead of polling.
Tips
- Anything that receives a call needs
start_thread_pool()— not only services. A client holding a service-notification callback, a callback object it passed to a service, or a death recipient is receiving inbound transactions, and without a thread pool (or a thread parked injoin_thread_pool()) those callbacks andbinder_diedsimply never fire. Only a purely outbound client can skip it. ProcessState::init_default()comes before any binder call and panics if it does not — but only once per process; further calls are harmless no-ops.- Name services reverse-domain,
com.example.myservice, so two crates in one process cannot collide. - Mutable state needs
MutexorRwLock. Every AIDL method takes&self.
Async Service
rsbinder supports async/await with the Tokio runtime, making it
straightforward to build non-blocking Binder services. rsbinder's own tokio feature is on
by default, but it enables only tokio/rt, tokio/rt-multi-thread and tokio/sync — what
the library itself uses (sync carries a death notification into a future; the waits use a
condvar, so no time). Your crate declares what its code needs, including the macros
feature that #[tokio::main] in the recipes below comes from:
tokio = { version = "1", features = ["rt-multi-thread", "macros"] }
# add "time" for the `sleep` / `timeout` examples, "signal" for `ctrl_c`
If you have not yet read the Hello, World! chapter, it is recommended to do so first -- the async concepts here build on the synchronous service and client covered there.
Async is transport-agnostic
Async lives in the AIDL-generated stubs, not in the transport. The compiler
emits the async trait (IMyServiceAsyncService), the async proxy
(into_async::<Tokio>()), and the new_async_binder constructor once, and
the very same async impl runs unchanged over kernel binder or RPC — including
through the transport-agnostic serve / connect entry API.
The reason is that the entry API and the transports only ever move a transport-agnostic
SIBinder around (register a binder under a name, look one up). Whether that binder is
sync- or async-backed is decided entirely by which constructor produced it
(new_binder vs new_async_binder) — a fact the transport never sees. So async needs
no transport-specific code: pick the constructor, pick the transport, and the two
compose.
This chapter gives one setup recipe, then shows what the RPC variant does and does not change about it.
Under the hood — it is a thread bridge, not a reactor. rsbinder async is not epoll-driven non-blocking I/O. On the client a transact runs on
tokio::task::spawn_blocking; on the server each call is wrapped inrt.block_on(..)and driven from a blocking worker thread (the binder thread pool for kernel, theserveworker for RPC). This is the same bridge the kernelbinder_tokiopath uses. You get async/await ergonomics and real concurrency, backed by blocking worker threads — which is sufficient for typical service workloads. A true async I/O reactor is deliberately out of scope.
Sync vs Async at a Glance
The following table summarizes the key differences between a synchronous and an asynchronous Binder service in rsbinder.
| Aspect | Sync | Async |
|---|---|---|
| Trait name | IMyService | IMyServiceAsyncService |
| Method signature | fn method(&self) -> Result<T> | async fn method(&self) -> Result<T> |
| Service creation | BnXxx::new_binder(impl) | BnXxx::new_async_binder(impl, rt()) |
| Remote call (client) | service.Method() | service.clone().into_async::<Tokio>().Method().await |
| Main loop | serve(..).run() | serve(..).spawn() + std::future::pending().await |
| Runtime | Not needed | Tokio runtime required |
The AIDL compiler generates both the sync trait (IMyService) and the async trait
(IMyServiceAsyncService) from the same .aidl file. You choose which one to implement
depending on whether your service needs async capabilities.
Every row above is identical across transports — only the URI passed to serve
changes. What does differ is how much slack the runtime flavor has: a current-thread
runtime is usable for kernel binder under a condition spelled out below, and not usable
at all for an RPC server that calls run(). Both cases come down to the same rule, so
the recipe that follows uses a multi-threaded runtime and spawn() throughout.
Setting Up the Tokio Runtime
An async Binder service must run inside a Tokio runtime. One recipe covers both transports:
- A multi-threaded runtime (what
#[tokio::main]builds by default). serve(..).add(..)— the same two calls as a sync service.spawn()rather thanrun(), so this task is not blocked.- Park on
std::future::pending().await.
use rsbinder::*; #[tokio::main] async fn main() -> std::result::Result<(), Box<dyn std::error::Error>> { // The runtime `block_on` runs each inbound call against. Binder threads are // outside it, which is the case `Handle::block_on` is built for. let rt = TokioRuntime(tokio::runtime::Handle::current()); let service = BnMyService::new_async_binder(MyAsyncService::default(), rt); // `serve(Uri::kernel())` initializes the kernel `ProcessState`; `add` // registers with the service manager. `spawn` starts the binder thread // pool and returns — unlike `run`, which joins it and never comes back. // Swap the `Uri` for `Endpoint::unix("/tmp/my.sock")` to serve the same // service over RPC; nothing else in this file changes, but rsbinder then // needs `features = ["rpc"]`. let _guard = rsbinder::serve(Uri::kernel())? .add("com.example.myservice", &service)? .spawn()?; // Never resolves, so the process stays alive. To shut down on Ctrl-C, // await `tokio::signal::ctrl_c()` here instead and declare `tokio/signal` // in your own `Cargo.toml` — rsbinder does not pull that feature in. std::future::pending::<()>().await; Ok(()) }
example-hello/src/bin/hello_async_service.rs is this code with a real interface.
Things worth naming:
TokioRuntimeis whatnew_async_binderdrives each inbound call with. It wraps aHandle, not an ownedRuntime: a binder that owns its runtime panics inRuntime::dropwhen the last handle to it is released on a worker thread.spawn()overrun(). For kernel binder,spawnstarts one looper and returns; the kernel grows the pool up tomax_threadsas load arrives.runjoins the pool, which blocks the calling thread forever — fine for a sync service, wrong here.pending().awaitis the async counterpart ofProcessState::join_thread_pool().rt()in the snippets below is this one-liner:#![allow(unused)] fn main() { fn rt() -> TokioRuntime<tokio::runtime::Handle> { TokioRuntime(tokio::runtime::Handle::current()) } }
Advanced: a current-thread runtime
A current-thread runtime works too, under one condition that is easy to miss: its
owning thread must stay parked inside Runtime::block_on. That is the only thing that
runs the runtime — its timer and IO drivers, and the tasks spawned on it. Park the owner
anywhere else — calling ProcessState::join_thread_pool() directly, joining a thread, a
blocking read — and every handler that awaits a timer, IO, or a task it spawned stops
making progress. There is no error; those calls simply never return.
So the recipe is the same shape as above, with the owner parked:
#![allow(unused)] fn main() { let runtime = tokio::runtime::Builder::new_current_thread() .enable_all() .build()?; runtime.block_on(async { let rt = TokioRuntime(tokio::runtime::Handle::current()); let service = BnMyService::new_async_binder(MyAsyncService::default(), rt); let _guard = rsbinder::serve(Uri::kernel())?.add("com.example.myservice", &service)?.spawn()?; std::future::pending::<()>().await; Ok(()) }) }
tests/src/bin/test_service_async.rs is built this way, and the integration suite runs the
whole client test suite against it — on a manual workflow dispatch, not on every pull
request. The multi-threaded recipe carries no such condition to get wrong, which is why it
is the default advice.
Calling a local async service through its sync handle
new_async_binder hands back a synchronous Strong<dyn IMyService>. Each of its
methods is implemented as block_on(inner.method()), so calling it from inside the
runtime — a gateway that calls its own service, start-up code, a test — re-enters the
runtime. On a multi-threaded runtime rsbinder handles that by releasing the worker's core
first.
Two independent things decide what such a call does. Read them as one list and you will attribute a failure to the wrong one.
Where you call from decides whether the call runs. Tokio refuses a block_on on a
thread already executing inside a runtime, and block_in_place — which steps back out of
one — exists only for the multi-threaded runtime. That is the whole rule:
| The calling thread | Result |
|---|---|
| Not executing inside a runtime | runs |
| Inside a multi-threaded runtime | runs — releasing the core first from a worker holding one, inline otherwise |
Inside a multi-threaded runtime, within a LocalSet | panic — block_in_place is refused there |
| Inside a current-thread runtime | panic — "Cannot start a runtime from within a runtime" |
Applying it takes some care, because "inside a runtime" is not "on a runtime's thread":
- A
spawn_blockingpool thread is outside the runtime it belongs to, either flavor. Those calls run. - A
Runtime::block_onbody is inside — so a current-thread runtime'sRuntime::block_onbody is the last row, not the first. - An async handler is inside for as long as
block_onis driving it, and inside the handle's runtime whatever thread it started on. A service whoseTokioRuntimeholds a current-thread handle therefore cannot reach a second local service through a sync handle from inside a handler — that is the last row, and it is the gateway pattern exactly. Useinto_asyncthere, or give the service a multi-threaded handle.
Which runtime the handle points at decides whether the call finishes, whatever the
answer above. The future itself is polled on the calling thread either way, and that is
all Handle::block_on does. Whatever the handler needs that runtime's scheduler to
advance — a timer, IO readiness, or a tokio::spawned task — needs that runtime to be
running:
The TokioRuntime handle | Result |
|---|---|
| A multi-threaded runtime | completes — its workers are always running |
A current-thread runtime with a thread parked in Runtime::block_on | completes — that parked call is what runs it |
| A current-thread runtime with nobody parked in it | never returns, for a handler that awaits any of the three above. One that needs none of them — ready on the first poll, or suspending only on yield_now — still completes |
spawn_blocking is not in that set: the blocking pool runs independently of the
scheduler, so awaiting one completes here too. That is what makes an outbound call to
another service work from a handler on a current-thread handle — rsbinder issues those
through spawn_blocking — where a tokio::spawned task in the same place would hang.
The cost is one core release per call made from a worker thread; from the other running
positions the call is inline. A released core is offered to the spawn_blocking pool,
which spawns a thread on demand — so it goes unclaimed only when the pool is at its
max_blocking_threads cap with every thread busy. Even then the call completes; what
stops is the rest of a single-worker runtime, until it returns. Two workers avoid that.
On a path that calls repeatedly, convert the handle once instead and await it. That route
dispatches straight to the async service and never touches block_on:
#![allow(unused)] fn main() { let svc = local_sync_handle.into_async::<Tokio>(); svc.method().await?; }
Async Over RPC
Because async lives in the generated stubs and rsbinder::serve / connect only move
an SIBinder, the same async service runs over RPC with no new code. You add the
result of new_async_binder and the client converts the looked-up proxy with into_async:
#![allow(unused)] fn main() { use async_trait::async_trait; use rsbinder::*; struct IHelloService; impl Interface for IHelloService {} #[async_trait] impl IHelloAsyncService for IHelloService { async fn echo(&self, echo: &str) -> rsbinder::BinderResult<String> { Ok(echo.to_owned()) } } }
The server needs a multi-threaded runtime
An RPC async server is driven by a blocking serve worker that calls
rt.block_on(handler) from a thread that is not the runtime's own. That part is fine for
either flavor — it is the same position a binder looper is in.
What rules out current_thread here is run(): it blocks the calling thread in the
accept loop. On a current-thread runtime that thread is the runtime's owner, and parking
it anywhere other than Runtime::block_on leaves nothing running that runtime — the
condition from Advanced: a current-thread runtime
above. Handlers would then hang the first time one awaits a timer, IO, or a task it
spawned. A multi-threaded runtime has no owner thread to strand, so run() is safe on
it.
fn main() -> std::result::Result<(), Box<dyn std::error::Error>> { // RPC async servers need a MULTI-THREAD runtime: run() below parks this // thread in the accept loop, which a current-thread runtime cannot survive. let runtime = tokio::runtime::Builder::new_multi_thread() .worker_threads(2) .enable_all() .build()?; let rt = TokioRuntime(runtime.handle().clone()); println!("serving over RPC at /tmp/hello.sock"); rsbinder::serve(Endpoint::unix("/tmp/hello.sock"))? .add("hello", BnHello::new_async_binder(IHelloService {}, rt))? .run()?; // blocks, driving this one socket Ok(()) }
Use spawn() instead of run() if you want the socket driven on a background thread
while main does other work (for instance from inside runtime.block_on).
The client converts the looked-up proxy
The client side is the ordinary connect followed by into_async — or
connect_async::<dyn IHelloAsync<Tokio>>(uri).await, which does the blocking connect on
spawn_blocking:
#![allow(unused)] fn main() { let runtime = tokio::runtime::Builder::new_multi_thread().enable_all().build()?; runtime.block_on(async { let uri = Endpoint::unix("/tmp/hello.sock").with_service("hello"); let hello = rsbinder::connect_async::<dyn IHelloAsync<Tokio>>(uri).await?; println!("{}", hello.echo("hi").await?); Ok::<_, Box<dyn std::error::Error>>(()) })?; }
The async-over-RPC bridge shown here (
new_async_binder+ the async proxy over a real Unix-socket session, multi-thread runtime) is verified end-to-end bytests/tests/rpc_async.rs— including many in-flight calls on one shared session under genuine async concurrency. That test drives the bridge through the lower-levelRpcSessionAPI directly;serve/connectare a thin wrapper over exactly that path.
Switching transports
To run the exact same async service over kernel binder instead, swap only the Uri —
the service impl is unchanged. Keep the multi-threaded runtime: run() parks the calling
thread in the binder pool, which a current-thread runtime's owner cannot afford (see
Advanced: a current-thread runtime) — use spawn()
there.
#![allow(unused)] fn main() { rsbinder::serve(Uri::kernel())? // instead of Endpoint::unix("/tmp/hello.sock") .add("hello", BnHello::new_async_binder(IHelloService {}, rt))? // the `rt` bound above .run()?; // joins the process-wide binder pool }
Be aware that the two transports' trust boundaries differ: see
Security & Authorization — @EnforcePermission denies over RPC, and
get_calling_uid() is the kernel-vouched peer uid on Unix RPC (fail-closed on uid-less
transports). The URI makes the transport swap easy; it does not make the security models
identical.
Implementing an Async Service
An async service struct implements the generated IMyServiceAsyncService trait using the
#[async_trait] attribute macro. Compare this with the sync version side by side.
Sync service
#![allow(unused)] fn main() { use rsbinder::*; #[derive(Default)] struct MyService; impl Interface for MyService {} impl IMyService::IMyService for MyService { fn echo(&self, input: &str) -> rsbinder::BinderResult<String> { Ok(input.to_owned()) } fn RepeatInt(&self, token: i32) -> rsbinder::BinderResult<i32> { Ok(token) } } }
Async service
#![allow(unused)] fn main() { use async_trait::async_trait; use rsbinder::*; #[derive(Default)] struct MyAsyncService; impl Interface for MyAsyncService {} #[async_trait] impl IMyService::IMyServiceAsyncService for MyAsyncService { async fn echo(&self, input: &str) -> rsbinder::BinderResult<String> { // You can use .await on async operations here. Ok(input.to_owned()) } async fn RepeatInt(&self, token: i32) -> rsbinder::BinderResult<i32> { Ok(token) } } }
The differences are minimal:
- Add
use async_trait::async_trait;and annotate theimplblock with#[async_trait]. - Implement
IMyServiceAsyncServiceinstead ofIMyService. - Prefix each method with
async. - When creating the binder, use
BnXxx::new_async_binder(impl, rt())instead ofBnXxx::new_binder(impl).
Inside async methods you can .await any future -- call other async services, perform async
I/O, use tokio::time::sleep, and so on.
Calling Other Services Asynchronously
When an async service needs to call another Binder service, the proxy it receives is
synchronous by default. Use into_async::<Tokio>() to convert it into an async proxy so
you can .await the result.
Async version
Based on tests/src/bin/test_service_async.rs:
#![allow(unused)] fn main() { async fn VerifyName( &self, service: &rsbinder::Strong<dyn INamedCallback::INamedCallback>, name: &str, ) -> rsbinder::BinderResult<bool> { service .clone() .into_async::<Tokio>() .GetName() .await .map(|found_name| found_name == name) } }
Sync version for comparison
From tests/src/bin/test_service.rs:
#![allow(unused)] fn main() { fn VerifyName( &self, service: &rsbinder::Strong<dyn INamedCallback::INamedCallback>, name: &str, ) -> std::result::Result<bool, rsbinder::Status> { service.GetName().map(|found_name| found_name == name) } }
The key difference is:
- Sync: Call
service.GetName()directly. - Async: Clone the service reference, convert with
.into_async::<Tokio>(), call.GetName(), and.awaitthe result.
The .clone() is necessary because into_async consumes the Strong reference. This is a
cheap reference-count increment, not a deep copy.
Cancellation and Nesting
Two properties of the async proxy surprise people who arrive from reactor-based async. Neither is going to change: the first follows from the binder wire having no cancel message, the second from how the kernel driver prevents deadlock.
Dropping a future does not cancel the call
A generated proxy method is not wrapped in an async block. It issues the transaction
when you call it, before the returned future is ever polled. So losing a timeout or
a select! race — or dropping the future without polling it once — discards the reply,
not the call:
#![allow(unused)] fn main() { // The remote side runs `transfer` either way. Only the reply is thrown away. let _ = tokio::time::timeout(Duration::from_millis(50), svc.transfer(amount)).await; }
Wrapping a non-idempotent call in a timeout and treating expiry as "it did not happen" is
therefore wrong. The blocking thread stays occupied until the reply arrives, so repeated
timeouts against an unresponsive server fill the spawn_blocking pool (512 threads by
default). Cap it with Builder::max_blocking_threads, and bound the wait at the source:
over RPC, the session timeout (RpcSession::set_timeout or ClientOptions::timeout,
RpcServer::set_reply_timeout on a server), whose expiry ends the whole session — see
Timeouts. Kernel binder has no such bound.
A call made inside a handler runs inline
While a thread is handling a transaction, an async proxy call on that thread executes at the call site, synchronously, before the first poll. This is deliberate: the kernel's deadlock prevention needs the nested outbound call to come from the thread that is holding the inbound one, and RPC needs the same thread to keep its session pin.
The consequence is that the usual combinators do nothing there:
#![allow(unused)] fn main() { // Inside a handler: expires never — the call has already completed. let r = tokio::time::timeout(d, other.call()).await; // Inside a handler: sequential, not concurrent. let (a, b) = tokio::join!(first.call(), second.call()); }
To get real concurrency, leave the transaction context with tokio::spawn and call from
there. That task's calls are no longer nested, so they also lose the deadlock avoidance —
do not call back into a service that is currently blocked waiting on you.
Creating Nested Async Binders
An async service can create and return new async binders, for instance when implementing a
factory-style method. Pass the same rt() helper:
#![allow(unused)] fn main() { async fn GetOtherTestService( &self, name: &str, ) -> rsbinder::BinderResult<rsbinder::Strong<dyn INamedCallback::INamedCallback>> { let mut service_map = self.service_map.lock().unwrap(); let other_service = service_map.entry(name.into()).or_insert_with(|| { let named_callback = NamedCallback(name.into()); INamedCallback::BnNamedCallback::new_async_binder(named_callback, rt()) }); Ok(other_service.to_owned()) } }
This pattern is taken directly from the test suite. Note that new_async_binder can be
called from any async context as long as a TokioRuntime is provided.
Registering Multiple Async Services
A single process can host multiple async services. Register each one before yielding to the runtime:
#![allow(unused)] fn main() { runtime.block_on(async { let service = BnTestService::new_async_binder( TestService::default(), rt(), ); hub::add_service(service_name, &service) .expect("Could not register service"); let versioned_service = BnFooInterface::new_async_binder( FooInterface, rt(), ); hub::add_service(versioned_service_name, &versioned_service) .expect("Could not register service"); let nested_service = INestedService::BnNestedService::new_async_binder( NestedService, rt(), ); hub::add_service(nested_service_name, &nested_service) .expect("Could not register service"); // All services are now registered. Yield to the runtime. std::future::pending().await }) }
All services share the same Tokio runtime and the same Binder thread pool. Incoming Binder transactions are dispatched to the correct service automatically.
Advanced: BoxFuture Pattern for Macros
When using declarative macros (macro_rules!) to reduce boilerplate in an #[async_trait]
impl block, there is a subtlety: async_trait transforms async fn into functions returning
a pinned boxed future, but it does not apply this transformation to functions produced by
macro expansion. The workaround is to return a BoxFuture manually.
This is the pattern used in the rsbinder test suite:
#![allow(unused)] fn main() { type BoxFuture<'a, T> = std::pin::Pin<Box<dyn std::future::Future<Output = T> + Send + 'a>>; macro_rules! impl_repeat { ($repeat_name:ident, $type:ty) => { fn $repeat_name<'a, 'b>( &'a self, token: $type, ) -> BoxFuture<'b, rsbinder::BinderResult<$type>> where 'a: 'b, Self: 'b, { Box::pin(async move { Ok(token) }) } }; } macro_rules! impl_reverse { ($reverse_name:ident, $type:ty) => { fn $reverse_name<'a, 'b, 'c, 'd>( &'a self, input: &'b [$type], repeated: &'c mut Vec<$type>, ) -> BoxFuture<'d, rsbinder::BinderResult<Vec<$type>>> where 'a: 'd, 'b: 'd, 'c: 'd, Self: 'd, { Box::pin(async move { repeated.clear(); repeated.extend_from_slice(input); Ok(input.iter().rev().cloned().collect()) }) } }; } }
These macros can then be used inside the #[async_trait] impl block alongside regular async fn methods:
#![allow(unused)] fn main() { #[async_trait] impl ITestService::ITestServiceAsyncService for TestService { impl_repeat! {RepeatInt, i32} impl_reverse! {ReverseInt, i32} async fn RepeatString(&self, input: &str) -> rsbinder::BinderResult<String> { Ok(input.into()) } // ... other methods } }
The lifetime annotations ('a: 'b, Self: 'b) are necessary to satisfy the borrow checker
when the future captures &self and method arguments. This is an advanced pattern -- you
will only need it when combining declarative macros with async trait implementations.
Compare with the sync macro version, which is simpler because no future is involved:
#![allow(unused)] fn main() { macro_rules! impl_repeat { ($repeat_name:ident, $type:ty) => { fn $repeat_name( &self, token: $type, ) -> std::result::Result<$type, rsbinder::Status> { Ok(token) } }; } }
Tips
- Prefer
new_multi_thread().new_current_thread()is correct only while its owning thread stays parked inRuntime::block_on; park it elsewhere and nothing runs that runtime — no error, just a handler that never returns the moment it awaits a timer, IO, or a task it spawned there. An RPC server that callsrun()parks it elsewhere by definition. A current-thread handle also rules out reaching a second local service through a sync handle from inside a handler. - Never block inside an async method. A CPU-bound or blocking call belongs
in
tokio::task::spawn_blocking, and a call to another binder service belongs behindinto_async::<Tokio>()— a blocking proxy call from an async method stalls the executor thread. - Budget threads in both directions. Outbound, each async call in flight holds one
blocking-pool thread until the reply lands; that thread talks to the driver but is not
a looper, so it does not come out of your
max_threads— it spends one of the callee's. Inbound, each handler holds one of your binder threads for the wholeblock_on, so awaiting slow IO inside a handler drains the pool atmax_threadsand nothing else gets served.Builder::max_blocking_threadscaps the outbound half. - Sync and async services coexist in one process. Register some with
new_binderand others withnew_async_binder; they share the same binder thread pool.
Summary
Async services in rsbinder follow the same structure as sync services, with a few additional steps:
- Build a multi-threaded Tokio runtime —
#[tokio::main]is one. - Implement
IMyServiceAsyncServicewith#[async_trait]instead ofIMyService. - Create binders with
BnXxx::new_async_binder(impl, TokioRuntime(Handle::current())). - Register them with
serve(uri).add(name, &svc)and start withspawn(), notrun(). - Keep the process alive with
std::future::pending().await. - Use
into_async::<Tokio>()when calling other Binder services from async code — including a local service of your own, where the sync handle would re-enterblock_on.
Because async lives in the generated stubs, the same async service runs over either
transport — add the new_async_binder result to rsbinder::serve(uri) and look it up
with connect. Nothing in the recipe changes but the Uri —
an RPC endpoint additionally needs rsbinder's rpc feature, which is not on by default.
For complete working examples, see tests/src/bin/test_service_async.rs (kernel binder) and
tests/tests/rpc_async.rs (RPC) in the rsbinder repository.
Callbacks and Interfaces
Binder IPC is not limited to one-way requests from client to service. Through callback interfaces, a client can pass a Binder object to a service, and the service can call methods on that object. This enables bidirectional communication across process boundaries without requiring the client to register itself as a separate service.
Defining a Callback Interface
A callback interface is a regular AIDL interface. The only difference is in how it is used: instead of being registered with the service manager, it is created by one process and passed to another through a method call.
Here is a minimal callback interface from the rsbinder test suite (INamedCallback.aidl):
package android.aidl.tests;
interface INamedCallback {
String GetName();
}
This interface defines a single method that returns a string. A service can accept objects implementing this interface, store them, and call GetName later -- regardless of whether the callback lives in the same process or a different one.
Implementing a Callback
Implementing a callback follows the same pattern as implementing any Binder service. Define a struct, implement the rsbinder::Interface trait, and implement the generated AIDL trait:
#![allow(unused)] fn main() { struct NamedCallback(String); impl rsbinder::Interface for NamedCallback {} impl INamedCallback::INamedCallback for NamedCallback { fn GetName(&self) -> std::result::Result<String, Status> { Ok(self.0.clone()) } } }
This implementation is identical in structure to a top-level service -- the only difference is that this object will be passed to another service rather than registered in the service manager.
Service-Side Callback Management
A service that works with callbacks typically needs to create, store, and invoke them. The following pattern uses a HashMap to cache callbacks by name:
#![allow(unused)] fn main() { #[derive(Default)] struct TestService { service_map: Mutex<HashMap<String, rsbinder::Strong<dyn INamedCallback::INamedCallback>>>, } impl Interface for TestService {} impl ITestService::ITestService for TestService { fn GetOtherTestService( &self, name: &str, ) -> std::result::Result<rsbinder::Strong<dyn INamedCallback::INamedCallback>, rsbinder::Status> { let mut service_map = self.service_map.lock().unwrap(); let other_service = service_map.entry(name.into()).or_insert_with(|| { let named_callback = NamedCallback(name.into()); INamedCallback::BnNamedCallback::new_binder(named_callback) }); Ok(other_service.to_owned()) } // ... } }
Key points:
BnNamedCallback::new_binder()wraps the struct in a Binder node so it can cross process boundaries. TheBnprefix stands for "Binder native" (server-side stub).Strong<dyn INamedCallback::INamedCallback>is a strong reference to a Binder object, equivalent to Android'ssp<INamedCallback>.Mutex<HashMap<...>>protects the map because Binder calls can arrive on different threads.
Accepting and Invoking Callbacks
A service can also accept callbacks from the client and invoke methods on them. The VerifyName method below receives a callback and calls its GetName method:
#![allow(unused)] fn main() { fn VerifyName( &self, service: &rsbinder::Strong<dyn INamedCallback::INamedCallback>, name: &str, ) -> std::result::Result<bool, rsbinder::Status> { service.GetName().map(|found_name| found_name == name) } }
When the client and service are in different processes, calling service.GetName() triggers a Binder transaction back to the client process. This is completely transparent to the service code -- the proxy handles all the marshalling.
Client-Side Usage
From the client side, working with callbacks is straightforward. You request a callback from the service, call methods on it, and pass it back to the service for verification:
#![allow(unused)] fn main() { let service = get_test_service(); // Request a callback from the service let got = service .GetOtherTestService("Smythe") .expect("error calling GetOtherTestService"); // Call a method on the callback assert_eq!(got.GetName().as_ref().map(String::as_ref), Ok("Smythe")); // Pass the callback back to the service for verification assert_eq!(service.VerifyName(&got, "Smythe"), Ok(true)); }
Even though the NamedCallback object was created inside the service process, the client can call GetName() on it through Binder IPC. The generated proxy (BpNamedCallback) handles serialization and deserialization automatically.
Callback Arrays
Services can return and accept arrays of callback interfaces. The GetInterfaceArray method creates a callback for each name in the input and returns them as a Vec. On the client side:
#![allow(unused)] fn main() { let names = vec!["Fizz".into(), "Buzz".into()]; let service = get_test_service(); let got = service .GetInterfaceArray(&names) .expect("error calling GetInterfaceArray"); // Each callback has the correct name assert_eq!( got.iter() .map(|s| s.GetName()) .collect::<std::result::Result<Vec<_>, _>>(), Ok(names.clone()) ); // Verify all names in a single call assert_eq!( service.VerifyNamesWithInterfaceArray(&got, &names), Ok(true) ); }
Nullable Arrays
Callback arrays can also be nullable, where both the array itself and individual elements may be absent:
#![allow(unused)] fn main() { let names = vec![Some("Fizz".into()), None, Some("Buzz".into())]; let got = service .GetNullableInterfaceArray(Some(&names)) .expect("error calling GetNullableInterfaceArray"); }
In this case, the service returns Option<Vec<Option<Strong<dyn INamedCallback::INamedCallback>>>> -- an optional array where each element is itself optional. The None entries in the input produce None entries in the output.
Passing Raw IBinder Objects
You can also pass raw IBinder objects through Binder transactions without committing to a specific interface type. The AIDL definitions use the IBinder type directly:
void TakesAnIBinder(in IBinder input);
void TakesANullableIBinder(in @nullable IBinder input);
void TakesAnIBinderList(in List<IBinder> input);
void TakesANullableIBinderList(in @nullable List<IBinder> input);
In Rust, IBinder maps to SIBinder (a strong Binder reference). You can obtain an SIBinder from any typed interface using the as_binder() method:
#![allow(unused)] fn main() { let service = get_test_service(); // Pass the service's own binder reference let result = service.TakesAnIBinder(&service.as_binder()); assert!(result.is_ok()); // Pass a list of binder references let result = service.TakesAnIBinderList(&[service.as_binder()]); assert!(result.is_ok()); // Nullable binder -- pass None let result = service.TakesANullableIBinder(None); assert!(result.is_ok()); // Nullable list with mixed Some/None entries let result = service.TakesANullableIBinderList( Some(&[Some(service.as_binder()), None]) ); assert!(result.is_ok()); }
Nested Interfaces
AIDL allows you to define interfaces, parcelables, and enums nested inside another interface. This is useful when a callback type is logically scoped to a single service. Here is the INestedService definition from the test suite:
interface INestedService {
@RustDerive(PartialEq=true)
parcelable Result {
ParcelableWithNested.Status status = ParcelableWithNested.Status.OK;
}
Result flipStatus(in ParcelableWithNested p);
interface ICallback {
void done(ParcelableWithNested.Status status);
}
void flipStatusWithCallback(ParcelableWithNested.Status status, ICallback cb);
}
Implementing a Nested Callback
In the generated Rust code, nested types are accessed through the parent module's namespace:
#![allow(unused)] fn main() { #[derive(Debug, Default)] struct Callback { received: Arc<Mutex<Option<ParcelableWithNested::Status::Status>>>, } impl Interface for Callback {} impl INestedService::ICallback::ICallback for Callback { fn done( &self, st: ParcelableWithNested::Status::Status, ) -> std::result::Result<(), Status> { *self.received.lock().unwrap() = Some(st); Ok(()) } } }
Using a Nested Callback
To create and pass a nested callback to the service:
#![allow(unused)] fn main() { let service: rsbinder::Strong<dyn INestedService::INestedService> = hub::wait_for_interface( <INestedService::BpNestedService as INestedService::INestedService>::descriptor(), ) .expect("did not get binder service"); let received = Arc::new(Mutex::new(None)); // Create the callback binder let cb = INestedService::ICallback::BnCallback::new_binder(Callback { received: Arc::clone(&received), }); // Pass NOT_OK to the service; it should flip it to OK via the callback let ret = service.flipStatusWithCallback( ParcelableWithNested::Status::Status::NOT_OK, &cb, ); assert_eq!(ret, Ok(())); // Verify the callback was invoked with the flipped status let received = received.lock().unwrap(); assert_eq!(*received, Some(ParcelableWithNested::Status::Status::OK)); }
The key detail is the fully-qualified path for the nested callback's Binder node: INestedService::ICallback::BnCallback. This follows the Rust module hierarchy generated from the AIDL nesting structure.
done(..) arrives as an inbound transaction into the client, so a client that passes a callback needs its binder thread pool running.
Service-Side Nested Callback Handling
On the service side, the nested callback is received as a typed strong reference and can be invoked directly:
#![allow(unused)] fn main() { impl INestedService::INestedService for NestedService { fn flipStatusWithCallback( &self, st: ParcelableWithNested::Status::Status, cb: &rsbinder::Strong<dyn INestedService::ICallback::ICallback>, ) -> std::result::Result<(), Status> { if st == ParcelableWithNested::Status::Status::OK { cb.done(ParcelableWithNested::Status::Status::NOT_OK) } else { cb.done(ParcelableWithNested::Status::Status::OK) } } } }
The service flips the status and calls done on the callback. If the callback lives in a different process, this triggers a Binder transaction back to the caller.
Death Recipients
When a client holds a reference to a remote Binder object, it may need to know if the remote process dies. In rsbinder, you implement the DeathRecipient trait and register it with a Binder reference.
Implementing a Death Recipient
#![allow(unused)] fn main() { use rsbinder::*; use std::sync::{Arc, Mutex}; use std::fs::File; use std::io::Write; struct MyDeathRecipient { write_file: Mutex<File>, } impl DeathRecipient for MyDeathRecipient { fn binder_died(&self, _who: &WIBinder) { let mut writer = self.write_file.lock().unwrap(); writer.write_all(b"binder_died\n").unwrap(); } } }
The binder_died method is called when the remote process hosting the Binder object terminates. The _who parameter is a WIBinder (weak Binder reference) identifying which Binder object died.
Registering and Unregistering
link_to_death_arc takes your Arc directly and is what you normally want:
#![allow(unused)] fn main() { let recipient = Arc::new(MyDeathRecipient { write_file: Mutex::new(write_file), }); service.link_to_death_arc(&recipient)?; // ... later service.unlink_to_death_arc(&recipient)?; }
The link holds only a weak reference, so it never keeps the recipient alive:
drop your last Arc and the notification silently stops. Keep recipient alive
for as long as you want to hear about the death.
link_to_death / unlink_to_death are the lower-level pair, taking a
Weak<dyn DeathRecipient> you build yourself
(Arc::downgrade(&(recipient.clone() as Arc<dyn DeathRecipient>))) — use them
when the recipient is already type-erased.
As a future
In async code, rsbinder::death_signal replaces the whole DeathRecipient
implementation with one select! arm (the tokio feature):
#![allow(unused)] fn main() { let died = rsbinder::death_signal(&service.as_binder())?; tokio::select! { result = service_call() => result?, _ = died => return Err("the service died mid-call".into()), } }
select! is tokio/macros, which rsbinder's tokio feature does not enable;
declare it on your own tokio dependency.
The signal holds the binder and the recipient itself, so there is no Arc or
handle for you to keep alive, and dropping it unregisters the notification. A
binder that is already dead is not an error — the future is simply complete
on arrival, which is the honest answer to "tell me when this dies". The two
conditions below still apply: a local binder is InvalidOperation, as with
link_to_death, and without a running thread pool the future never completes.
Two conditions are easy to miss:
- Death notification works only for a remote binder. Linking to a local one returns an error; there is no other process to outlive.
binder_diedarrives as an inbound transaction, so the linking process needs its thread pool running, even if it otherwise only makes outbound calls.- Over RPC the same need is checked when you link: a client session that nothing
reads — no incoming connections and no serve loop — would never see its
connection drop, so
link_to_death(anddeath_signal) returnInvalidOperationthere, as AOSP does. Open incoming connections (ClientOptions::incoming_connections) or startRpcSession::spawn_servefirst; see RPC Transport.
Tips
-
Equality is binder identity. Two
Strong<dyn T>compare equal when they name the same binder object, so a service can tell whether two callbacks arriving by different routes are in fact the same one — which is how you de-duplicate a registration list. -
A callback is an inbound path into your process. Whichever side hands out the callback is the side that then serves transactions on it, and needs a thread pool and
Mutex-protected state to do so. -
as_binder()drops down toSIBinderwhen you needIBinder-typed parameters or binder-level calls likeping_binder.
ParcelFileDescriptor
Binder IPC typically transfers structured data -- integers, strings, parcelables -- but
sometimes you need to pass a file descriptor from one process to another. The
ParcelFileDescriptor type makes this possible by wrapping an OwnedFd so that it
can be serialized into a Binder Parcel, sent across process boundaries, and
deserialized on the other side.
Common use cases include sending pipe endpoints to a service so it can stream data
back, sharing access to an open file or socket, and implementing dump() for
diagnostic output.
Creating a ParcelFileDescriptor
ParcelFileDescriptor::new() accepts any type that implements Into<OwnedFd>,
including std::fs::File, OwnedFd, and the file descriptors returned by
rustix::pipe::pipe().
#![allow(unused)] fn main() { use std::fs::File; // From an existing File let file = File::open("/dev/null").unwrap(); let pfd = rsbinder::ParcelFileDescriptor::new(file); // A pipe, both ends already wrapped (AOSP `createPipe`). Both are // O_CLOEXEC. let (read_pfd, write_pfd) = rsbinder::ParcelFileDescriptor::pipe().unwrap(); }
Since 0.10.0 ParcelFileDescriptor also implements From<std::fs::File>
and From<OwnedFd>, so the common concrete cases work with plain
conversions — let pfd: ParcelFileDescriptor = file.into();. The generic
new() covers any other Into<OwnedFd> source.
Sending a File Descriptor to a Service
A typical pattern is to create a pipe, wrap one end in a ParcelFileDescriptor,
send it to a service method, and then read from or write to the other end locally.
The following example is based on the test_parcel_file_descriptor test in the
rsbinder test suite:
#![allow(unused)] fn main() { use std::io::{Read, Write}; let (read_end, write_pfd) = rsbinder::ParcelFileDescriptor::pipe()?; // Send the write end to the service; it returns a (duplicated) copy let result_pfd = service.RepeatParcelFileDescriptor(&write_pfd)?; // Write through the returned file descriptor (&result_pfd).write_all(b"Hello")?; // Read from the original pipe's read end let mut buf = [0u8; 5]; (&read_end).read_exact(&mut buf)?; assert_eq!(&buf, b"Hello"); }
Because the service duplicates the descriptor before returning it (see the next section), both the caller and the service hold independent handles to the same underlying pipe.
Duplicating File Descriptors in a Service
When a service receives a ParcelFileDescriptor, it usually needs to duplicate
the descriptor before returning it or storing it. This avoids ownership conflicts
and ensures each side can close its handle independently.
Since 0.10.0 the type has a built-in ParcelFileDescriptor::try_clone()
that duplicates the underlying descriptor with fcntl(F_DUPFD_CLOEXEC)
(the duplicate is always close-on-exec, matching AOSP
ParcelFileDescriptor::dup), so no hand-rolled dup helper is needed.
A service method that repeats a file descriptor back to the caller is then
straightforward:
#![allow(unused)] fn main() { use rsbinder::ParcelFileDescriptor; fn RepeatParcelFileDescriptor( &self, read: &ParcelFileDescriptor, ) -> rsbinder::BinderResult<ParcelFileDescriptor> { Ok(read.try_clone()?) } }
Working with File Descriptor Arrays
AIDL interfaces can accept and return arrays of ParcelFileDescriptor. The
pattern for reversing an array -- a common test case -- illustrates how to
combine try_clone() with iterator combinators:
#![allow(unused)] fn main() { fn ReverseParcelFileDescriptorArray( &self, input: &[ParcelFileDescriptor], repeated: &mut Vec<Option<ParcelFileDescriptor>>, ) -> rsbinder::BinderResult<Vec<ParcelFileDescriptor>> { repeated.clear(); for fd in input { repeated.push(Some(fd.try_clone()?)); } input.iter().rev().map(|fd| Ok(fd.try_clone()?)).collect() } }
The repeated output parameter receives a copy of the input in the original
order, while the return value contains the input in reverse order. Every
descriptor is duplicated so that each Vec owns its own set of file handles.
Reading and Writing
ParcelFileDescriptor implements Read and Write — both by value and by
reference, as std::fs::File does — so a descriptor you hold or one a service
returned is used directly:
#![allow(unused)] fn main() { use std::io::{Read, Write}; let (read_end, write_end) = rsbinder::ParcelFileDescriptor::pipe()?; // Borrowed: the descriptor stays yours, e.g. to send in a parcel. (&write_end).write_all(b"payload")?; let mut buf = [0u8; 7]; (&read_end).read_exact(&mut buf)?; }
They mean what File's do: reading at end of file is Ok(0) — for a pipe,
every writing end is gone — and writing into a pipe whose reader has closed is
BrokenPipe. flush() is a no-op, since the only buffer involved is the
kernel's.
A pipe holds about 64 KB. Writing more than that blocks until someone drains it, so the usual shape is: create the pipe, send the read end, then fill it — from another thread if the same process is doing both.
For tokio, convert once rather than per call:
#![allow(unused)] fn main() { use std::os::fd::OwnedFd; let file = std::fs::File::from(OwnedFd::from(read_end)); let mut async_file = tokio::fs::File::from_std(file); }
rsbinder does not wrap that: its tokio feature deliberately does not pull
tokio/fs.
File Descriptors over RPC
ParcelFileDescriptor also crosses the socket-based
RPC transport (the rpc Cargo feature). Unlike
kernel binder, FD passing over RPC is opt-in on both ends and fixed
per session: the server declares the modes it accepts with
RpcServer::set_supported_fd_modes, and the client requests one before
connecting with the fd_mode knob on the RpcClientConfig builder (the
fd_mode connect option of the entry API). On the android-14+ wire the
mode rides the connection header, as in AOSP; an android-13 (v0) session
passes no fds. On the r34 wire, between two rsbinder peers only,
RpcSession::negotiate_fd_transport agrees the mode after connecting.
Descriptors then travel out-of-band via SCM_RIGHTS, which requires a
Unix-domain socket. Service and client code using ParcelFileDescriptor
is otherwise unchanged. See
RPC Transport for details.
Tips
-
Each side owns its own descriptor. Serializing a
ParcelFileDescriptormakes the kernel duplicate the fd for the receiver, so the two handles are independent and close order does not matter. -
ParcelFileDescriptoris notClone— it wraps anOwnedFd, which owns the descriptor. Usetry_clone(), and use it before storing or re-returning a descriptor you received. -
try_clone()can fail when the process is out of file descriptors. Propagate it with?rather thanunwrap(); aStatusCodeconverts intoStatusautomatically.
Shared Memory
Binder transactions copy their payload through the kernel, and a single transaction is capped at the binder buffer size (1 MiB per process by default). For frames, audio chunks, or any buffer you would rather not copy per call, Android services share memory instead: the buffer is an anonymous shared region, only its file descriptor crosses the binder, and both processes map the same pages.
rsbinder's shared_memory module provides that pattern on Linux, Android
and macOS, wire-compatible with Android's own types:
| rsbinder | Android equivalent | on the wire |
|---|---|---|
SharedMemory | android.os.SharedMemory, NDK ASharedMemory | one ParcelFileDescriptor |
MemoryHeapBase / BnMemoryHeap / BpMemoryHeap | libbinder IMemoryHeap (android.utils.IMemoryHeap) | handwritten AOSP transaction |
MemoryBase / BnMemory / BpMemory | libbinder IMemory (android.utils.IMemory) | handwritten AOSP transaction |
MemoryDealer / Allocation | libbinder MemoryDealer | IMemory windows on one heap |
The full example lives in
example-hello/src/bin/shm_service.rs
and
shm_client.rs;
this page walks through it.
Which transports can carry a region?
A shared region is a file descriptor, so it travels wherever an fd can:
- Kernel binder (Linux with binderfs, Android) — always.
- Unix-socket RPC — when both sides opt into fd passing
(
FileDescriptorTransportMode::Unix, see below). - TCP / vsock / TLS RPC — never through the entry API, which refuses
the
Unixfd mode on these transports; writing a region into such a parcel fails withStatusCode::FdsNotAllowed, exactly like a plainParcelFileDescriptor.
0. One large value: Parcel::write_blob
Before reaching for a region of your own, note that a byte payload has a
ready-made form. Parcel::write_blob carries it inline when it is at most
16 KB and through a shared region when it is larger, tagging the wire so the
reader knows which it got. The bytes are Java's Parcel.writeBlob: an int32
length, then what C++ Parcel::writeBlob writes. A C++ peer reads the length
itself and passes it to Parcel::readBlob(len, …), as the JNI layer under
Java does:
#![allow(unused)] fn main() { parcel.write_blob(&payload, false)?; // false: the reader may not write it let blob = parcel.read_blob()?; let bytes = blob.to_vec()?; // whichever form it took }
Two properties make it the right default for "a big lump of bytes":
- The transport decides, not your code. Where fds cannot travel — vsock,
TLS, or a data-only parcel — the payload simply goes inline, as AOSP does
when
mAllowFdsis false. There is nothing to configure and no error to handle.Parcel::allow_fds()answers the same question if you want to know in advance. - An immutable blob is sealed (
F_SEAL_FUTURE_WRITE), so the reader maps a snapshot it cannot modify. Passtrueto leave the reader's copy writable (BLOB_ASHMEM_MUTABLE). The writer keeps no handle on the region, so nothing the reader writes comes back; for a buffer both sides share, use a region of your own (below).
Reach for the sections below when you need the region itself: a window into a larger heap, a lifetime longer than one call, or many buffers out of one allocation.
1. One region: SharedMemory + ParcelFileDescriptor
This is the simplest and the most portable form — it is what Android apps
do with SharedMemory. In AIDL the region is just a ParcelFileDescriptor:
package shm;
interface IShm {
ParcelFileDescriptor getRegion();
void regionWritten(int offset, int len); // "data is ready" signal
IBinder nextFrame(int seq); // see section 2
}
Service side
#![allow(unused)] fn main() { use rsbinder::shared_memory::SharedMemory; struct ShmService { region: SharedMemory, // … } impl ShmService { fn new() -> rsbinder::Result<Self> { let region = SharedMemory::create(64 * 1024)?; // page-rounded region.write_at(0, b"hello from shm_service")?; // through our own mapping Ok(Self { region }) } } impl IShm for ShmService { fn getRegion(&self) -> rsbinder::BinderResult<ParcelFileDescriptor> { // dup the fd for the parcel; the service keeps its mapping Ok(self.region.to_parcel_fd()?) } fn regionWritten(&self, offset: i32, len: i32) -> rsbinder::BinderResult<()> { let mut buf = vec![0u8; len as usize]; self.region.read_at(offset as usize, &mut buf)?; // the client's bytes Ok(()) } // … } }
Client side
#![allow(unused)] fn main() { use rsbinder::shared_memory::SharedMemory; let shm: Strong<dyn IShm> = client.get::<dyn IShm>(SERVICE_NAME)?; let pfd = shm.getRegion()?; let region = SharedMemory::from_fd(pfd.into())?; // size recovered from the fd let mut greeting = [0u8; 22]; region.read_at(0, &mut greeting)?; // same physical pages region.write_at(4096, b"hello back")?; // visible to the service at once shm.regionWritten(4096, 10)?; // tell it to look }
SharedMemory::from_fd accepts anything Android would send here: a memfd,
a macOS POSIX shm object, or a legacy /dev/ashmem fd (its size is read
with ASHMEM_GET_SIZE). A region that was sealed read-only by the owner
maps read-only automatically (is_read_only()), and write_at then
returns PermissionDenied.
Synchronisation is yours
The library does not serialise access to the pages: a region is, by
definition, writable by the other process at any time. Use an ordinary
binder call as the hand-off signal (regionWritten above), the way AOSP
services do, and prefer the copying accessors read_at / write_at over
holding a &[u8] into the region.
2. Many buffers: MemoryDealer + IMemory
Allocating a region per frame means an fd and an mmap per frame on
both sides. AOSP's MemoryDealer avoids that: one heap is created and
mapped once, and each frame is an (offset, size) window of it — an
android.utils.IMemory binder whose reply names the shared heap.
Service side
#![allow(unused)] fn main() { use rsbinder::shared_memory::{Allocation, MemoryDealer}; struct ShmService { dealer: Arc<MemoryDealer>, frames: Mutex<VecDeque<Allocation>>, // bounded; dropping returns the block } fn nextFrame(&self, seq: i32) -> rsbinder::BinderResult<SIBinder> { let frame = self.dealer.allocate_page_aligned(4096)?; // NoMemory when full frame.write_at(0, format!("frame #{seq}").as_bytes())?; let binder = frame.export(); // the IMemory binder let mut frames = self.frames.lock().unwrap(); frames.push_back(frame); if frames.len() > 4 { frames.pop_front(); // Allocation::drop → block back to the dealer } Ok(binder) } }
The allocator is AOSP's SimpleBestFitAllocator: 32-byte granules,
best-fit, neighbours coalesce on free, and allocate_page_aligned for
buffers that must start on a page. Peers that still hold an exported
binder keep reading the window after the Allocation is dropped; only
the dealer's bookkeeping changes — so keep a frame alive until the
consumer is done with it.
Client side
#![allow(unused)] fn main() { use rsbinder::shared_memory::{BpMemory, HeapCache}; let cache = HeapCache::new(); // one per session for seq in 0..6 { let binder = shm.nextFrame(seq)?; let frame = BpMemory::new_with_cache(binder, cache.clone()); let heap = frame.resolve()?; // GET_MEMORY; HEAP_ID + mmap only the first time let mut buf = [0u8; 40]; frame.read_at(0, &mut buf)?; // offset is relative to the window assert_eq!(cache.len(), 1); // one mapped heap behind every frame } }
Without the cache each BpMemory maps the heap on its own (still
correct, just more VMAs); with it, frames from the same dealer resolve to
bare offsets.
Because the transactions are the handwritten AOSP layout, the same
binders work against a C++ interface_cast<IMemory> client or a C++
MemoryHeapBase server — that interop is part of rsbinder's test gate
(example-hello/cpp/run_imemory_interop.sh).
Running the example
Kernel binder (Linux with rsb_hub running, or an Android device):
cargo run -p example-hello --bin shm_service kernel &
cargo run -p example-hello --bin shm_client kernel
Unix-socket RPC (works on macOS too; fd passing needs the rpc feature):
cargo run -p example-hello --features rpc --bin shm_service rpc &
cargo run -p example-hello --features rpc --bin shm_client rpc
Over RPC both ends must opt into fd passing before connecting. Through the
entry API it is an option on the connect; on an android-14+ session you
built yourself it is RpcClientConfig::fd_mode on the client and
RpcServer::set_supported_fd_modes on the server, since the connection
header fixes the mode. Only an r34 session negotiates it afterwards, with
RpcSession::negotiate_fd_transport:
#![allow(unused)] fn main() { // service rsbinder::serve(&uri)? .with(|o| o.fd_modes = Some(vec![FileDescriptorTransportMode::Unix])) .add(SERVICE_NAME, BnShm::new_binder(ShmService::new()?))? .run()?; // client let client = rsbinder::Client::open_with(&uri, |o, _endpoint| { o.fd_mode = Some(FileDescriptorTransportMode::Unix); })?; }
Both options are rejected on an endpoint that cannot carry fds, so ask
endpoint.supports_fd_passing() first when the URI is not fixed (as
shm_client.rs
does). Without the opt-in, getRegion() fails with FdsNotAllowed.
Expected client output:
shm_client: region 65536 bytes, read-only=false, says "hello from shm_service"
shm_client: frame #0 @ offset 0 (window 0+4096 of a 1048576 byte heap, 1 mapped heap(s))
shm_client: frame #1 @ offset 4096 … (16384 on a 16 KiB-page macOS)
…
shm_client: frame #5 @ offset 0 … ← a recycled block
What the kernel enforces
| property | Linux / Android (memfd) | macOS (POSIX shm) |
|---|---|---|
| size cannot change after creation | F_SEAL_SHRINK | F_SEAL_GROW | inherent: one ftruncate only |
read-only for peers (FLAG_READ_ONLY, seal_read_only) | F_SEAL_FUTURE_WRITE | the owner exports an O_RDONLY fd |
| receiver can verify | F_GET_SEALS | fcntl(F_GETFL) |
MemoryHeapBase::seals() reports these as the same SEAL_* bits on every
platform, and MappedHeap::from_fd_strict rejects a sender whose claims
the kernel does not back. One macOS difference to know: sealing read-only
after a writable fd was already handed out does not revoke that fd —
create the region with FLAG_READ_ONLY when peers must never write.
Streaming
A binder method returns one value. A method that would return a million rows
either builds the whole list in memory on both sides — and runs into the
transaction size limit long before that — or invents a paging protocol.
rsbinder::stream is the third option: the consumer makes an endpoint and
hands it to the producer in the one call that opens the stream; the producer
writes items into it and can only run as far ahead as the consumer allows.
It is for a sequence in which every item matters. Three neighbours cover what it is not for:
| What you have | Use | Why |
|---|---|---|
| A sequence of values, all of which must arrive | rsbinder::stream (this chapter) | Items cannot be dropped, so a slow consumer has to be able to slow the producer down |
| A value that changes, where only the current one matters | A oneway callback — see below | Stale values may be dropped, so nothing needs slowing down |
| Bytes — a file, a log, a media stream | ParcelFileDescriptor::pipe | The kernel already does back-pressure on a pipe |
| A large buffer both sides read in place | Shared memory | No copy at all |
New in 0.12.0. The same two types run over kernel binder and over
RPC, on different wires: over kernel binder the items
travel on a shared-memory ring the consumer allocates, over RPC as oneway
batches paced by credit — see What is on the wire.
The RPC side has one requirement on the client.
A minimal stream
The method that starts a stream takes the consumer's endpoint —
rsbinder.stream.StreamEndpoint<T>, a parcelable shipped in
rsbinder/aidl/stream/ that rsbinder-aidl resolves without a copy of the
file — and returns nothing. The type argument is the item type. Everything
else travels over the stream itself:
package demo;
import rsbinder.stream.StreamEndpoint;
parcelable LogLine {
long timestampMs;
String text;
}
interface ILogService {
void tail(in StreamEndpoint<LogLine> endpoint, String tag);
}
The service opens a sink on the endpoint and writes from a thread of its own.
The endpoint's type argument makes it a Sink<LogLine>. send blocks once the
consumer has fallen behind, which is the back-pressure — and the reason the
producer does not run on the binder thread that took the call:
#![allow(unused)] fn main() { use rsbinder::stream::{Sink, StreamEndpoint}; impl ILogService for LogService { fn r#tail(&self, endpoint: &StreamEndpoint<LogLine>, tag: &str) -> rsbinder::BinderResult<()> { let mut sink = Sink::open(endpoint)?; let lines = self.open(tag); std::thread::spawn(move || { for line in lines { if sink.send(&line).is_err() { return; // cancelled, or the consumer is gone } } let _ = sink.end(); }); Ok(()) } } }
The consumer makes a receiver against its peer — a binder in the producer's process, here the service it is about to call — passes the endpoint it gets back, and reads:
#![allow(unused)] fn main() { use rsbinder::stream::Receiver; let (mut lines, endpoint) = Receiver::<LogLine>::new(&service.as_binder())?; service.r#tail(&endpoint, "net")?; for line in &mut lines { println!("{}", line?.text); } }
The peer decides two things: which transport the stream runs on (a kernel
proxy makes a ring endpoint, and so does a local object on Linux and Android;
an RPC proxy makes a sink-only one unless the consumer opts in to a ring — see
A ring over a Unix session — and so does a local
object elsewhere) and whom the consumer watches for death. Because the opening call is twoway,
a refusal — the transport cannot carry the stream, the ring is larger than the
producer accepts, SELinux denies the mapping — comes back as that call's error.
On Android the default SELinux policy grants memfd_file access only on a
domain's own memfds, and the ring is the consumer's memfd, which the producer
checks, reads and writes. A producer in another domain therefore needs
memfd_file { getattr read write map } on the consumer's domain, besides the
fd use any descriptor passed between the two needs — a platform service that
streams to apps carries
allow <service> appdomain:memfd_file { getattr read write map }; in its own
.te — or Sink::open fails with EACCES.
When the client is the producer — an upload — the direction of the call
flips: the client passes a binder of its own (or a stream::Token made for the
purpose), the service makes the receiver against it in the handler and returns
the endpoint, and the client opens its sink on the returned value:
StreamEndpoint<Chunk> upload(IBinder producer);
#![allow(unused)] fn main() { // Client let token = rsbinder::stream::Token::new(); let endpoint = service.r#upload(&token.binder())?; let mut sink = Sink::open(&endpoint)?; // a Sink<Chunk> // ... keep `token` alive for as long as the stream runs. }
Either way the endpoint crosses in the reply or the arguments of one twoway
call. A service that wants to start a stream from a oneway callback — an
event it pushes to the client — has no reply to carry the endpoint, so the
consumer needs one more twoway call to hand it over.
Receiver<T> is an Iterator<Item = BinderResult<T>> that ends when the
producer does; recv, try_recv and recv_timeout are the same thing one item
at a time, differing only in how long they wait for one. Items are encoded with
the codec to_bytes uses, so an item may hold
neither a binder nor a file descriptor; send refuses one that does.
The item type is checked at compile time
The type argument is where the .aidl states what the items are, and both ends
are held to it: the generated method takes a StreamEndpoint<LogLine>, only a
Receiver<LogLine> makes one, and Sink::open on it is a Sink<LogLine>. A
consumer that reads the stream as some other type, or a service that writes
one, does not compile.
That matters most for the mismatches nothing else would catch. The items carry
no type tag, only their encoding, so the consumer ends the stream with an error
only when decoding happens to notice — an item's bytes running out
(NotEnoughData) or left over (BadValue), a null marker where a value is
required (UnexpectedNull). A mismatch it does not notice — int read as
float, two parcelables with the same fields — decodes as the wrong value.
The type argument is not on the wire: the endpoint is the same bytes whatever
it names, so a C++ peer built from the same .aidl (where it is
StreamEndpoint<LogLine> too, a class template) interoperates. For the same
reason a Rust end may read or write a type of its own that encodes like the
declared one; StreamEndpoint::cast::<U>() says so explicitly, and nothing then
checks that the two agree. A service handler receives &StreamEndpoint<T>, so
it casts a copy: Sink::<U>::open(&endpoint.try_clone()?.cast()). A producer
holding borrowed items needs no cast: Sink::<str>::open_borrowed takes a
StreamEndpoint<String> and sends &str lines without a String for each.
rsbinder-aidl refuses an array or a List as T (AOSP's aidl accepts
either); wrap one in a parcelable.
What is on the wire
StreamEndpoint has two fields, ring and sink, and which one carries the
items depends on the transport the two ends share.
Kernel binder: a ring
The endpoint carries a ring: a synchronized Fast Message Queue of bytes
(android.hardware.common.fmq.MQDescriptor<byte, SynchronizedReadWrite>, the
same type libfmq uses) that the consumer allocates — sealed against
shrinking and charged to its own process — and the producer maps. Each item
is one record written in place: a 4-byte header (kind bit plus payload
length) followed by the item's parcel bytes. A full ring parks the producer on
the ring's futex until the consumer reads; an empty ring parks the consumer
until the producer writes. Before parking, a blocking call looks at the ring
for up to 20 µs (not on a single-core machine, never on an async executor
thread, not past a first short round while threads for half the cores already
look, and only on every eighth wait of a side whose last 64 looks found
nothing), and the consumer copies every record the producer has committed out
at once and frees the space with one wake. A look costs CPU while it lasts: it
pays where the other end is about to act, and is turned down where items come
further apart than the look or more threads look than the cores can run. After the opening call no binder call carries
anything in either direction: the end of the stream is the last record, and
a cancel is a bit in the ring's EventFlag word. The sink binder is still in
the endpoint, but the producer only links to it for death.
The record layout, the end reserve and the three EventFlag bits (NOT_FULL,
NOT_EMPTY, CANCEL) are documented in StreamEndpoint.aidl; a C++ peer on
libfmq implements against that.
RPC: two oneway interfaces
By default an RPC session carries no shared memory, so the endpoint carries
only the consumer's sink (a consumer may opt in to a ring on a Unix session,
below), and the stream is two ordinary AIDL interfaces, shipped
beside the parcelable in rsbinder/aidl/stream/:
package rsbinder.stream;
interface IStreamSink { // implemented by the consumer
// `source` is an IStreamSource, `credits` the window the producer opens
// with; sent once, first.
oneway void onStart(IBinder source, int credits);
oneway void onBatch(in byte[] items, int count);
oneway void onEnd(int exception, int serviceSpecific, @nullable String message);
}
interface IStreamSource { // implemented by the producer
// `total` batches granted in all since the stream began — a running
// total, not an amount to add.
oneway void request(long total);
oneway void cancel();
}
This is the reactive-streams contract
under binder names: onStart / onBatch / onEnd are onSubscribe / onNext
/ onComplete-or-onError, and IStreamSource is the Subscription. It
differs in one place: reactive-streams' request(n) adds n, and
request(total) here states a running total. A oneway call that reports
failure has almost always not arrived, but its sender cannot always tell, and
an amount sent again would then be counted twice or not at all. A total is
counted once whichever happened, which is what lets the two ends agree on how
many batches the producer may send.
Every call is oneway. A session keeps oneway calls to one object in
order, which is what guarantees the source is known before the first batch and
that onEnd cannot overtake the last one. It also means neither end ever waits
on the other's threads: granting credit costs the consumer one send, however
busy the producer's process is — a send that waits for a free outgoing
connection on the session, see Over RPC.
Credit, batches and the two policies
Flow control is set on each side when the stream is made:
Receiver::with_policy(&peer, &ReceiverPolicy { .. }) on the consumer and
Sink::open_with(&endpoint, &SinkPolicy { .. }) on the producer. Each policy
has fields for both paths; the ones for the path not in use are
ignored. A ring over a Unix session takes the ring fields and ping; the
credit and batch fields do not apply to it. Receiver::new and Sink::open
take the defaults.
| Policy field | Path | Default | What it sets |
|---|---|---|---|
ReceiverPolicy::ring_bytes | ring | 64 KiB | The ring the consumer allocates — the whole of the flow control (the producer runs ahead by under twice it), and the bound on the largest item |
SinkPolicy::max_ring_bytes | ring | 4 MiB | The largest ring the producer maps; a bigger one is refused at Sink::open |
ReceiverPolicy::ring_use | RPC session | KernelOnly | Whether an RPC peer may get a ring; see A ring over a Unix session |
SinkPolicy::max_batch_bytes | calls | 16 KiB | The byte threshold at which a pending batch is sent |
SinkPolicy::initial_credits | calls | 4 | The window the producer opens with — its ceiling on batches in flight |
SinkPolicy::send_timeout | both | None | How long one send/send_all/flush/end call (or *_async future, from its first poll) may wait for ring room or credit before it returns TimedOut; the item is not written or queued and the stream stays usable. Some(Duration::ZERO) never waits. It does not bound the RPC session's own send deadline |
ReceiverPolicy::credit_window | calls | 4 | The consumer's grant threshold: a grant leaves once half of it is owed |
ReceiverPolicy::max_opening | calls | 4 | The widest opening window the consumer accepts |
SinkPolicy::ping, ReceiverPolicy::ping | RPC session | Inherit | Whether a wait that hears nothing from the other end pings it; see a peer that goes silent |
On the ring the ring's size is everything: the producer waits for room once
the ring is full. The consumer copies every record in the ring out at once and
frees the ring right away, so the producer can be ahead of what recv has
returned by the ring plus the records the consumer has copied out and not yet
returned — under twice ring_bytes, and the consumer's copy buffer grows to
at most the ring's size, plus a decode buffer kept at the largest item's size. The last END_RESERVE (256) bytes are never given to items, so the end
record always has room; the largest item is therefore
ring_bytes - END_RESERVE - 4, and a stream of large items needs a larger
ring. The memory is allocated up front and charged to the consumer's process.
The default of 64 KiB is sixteen pages; the producer's max_ring_bytes protects its
own address space against a consumer that describes a ring of any size. Once
the ring holds a few of the largest items, a larger ring mainly lets the
producer run further ahead; one that holds only one or two makes the two ends
take turns.
Over RPC credit is counted in onBatch calls. The producer opens with a
window of credit, spends one per batch, and waits when it has none; the
consumer grants credit back for the batches it has taken — half a window at a
time while more batches are already queued, and everything it owes before it
goes to wait.
Only the producer's opening window bounds what is in flight. A grant pays for a
batch the consumer has already drained, one for one, so the ceiling stays
whatever initial_credits was; credit_window sets the grant threshold — a
grant leaves once half of it is owed, or the whole opening window if that is
less — not how far the producer may run ahead.
The consumer holds the producer to its credit. The producer states its
opening window in onStart, so the consumer knows everything the producer may
send: that window, plus what has been granted since. A batch beyond it ends the
stream with EX_ILLEGAL_STATE instead of being queued, which is what bounds the
memory a producer can make the consumer's process hold. That matters most when
the consumer is the service — an upload — and its producer is a client it has no
reason to trust.
How wide a window a consumer takes is the consumer's to say: max_opening. A
producer that opens wider is refused at the start, with EX_ILLEGAL_ARGUMENT,
before any item. The two defaults are the same number, so a producer made with
Sink::open is always taken by a consumer made with Receiver::new; a wider
window has to be asked for on both sides. The producer hears of a refusal as a
cancel — onStart is oneway — and the reason is in the consumer's error and
in its log.
The batch size is a threshold, not a cap: an item larger than it still goes out, with the batch it was added to.
When a batch leaves
Batching is an RPC-path matter. On the ring every accepted item is already in
the ring when send returns; nothing is queued, and flush returns at once —
unless a dropped send_async left a record on the pool, which flush waits
for (see Async).
Over RPC two things send a batch, and a clock is neither of them: the pending
batch reaching the byte threshold, and a call to flush or end.
- Items already in hand — a query result, a directory listing. The
threshold does the batching and
endsends the remainder. - Items arriving at their own pace — an event log, a progress feed. Call
flushwhen what has been sent so far is what the consumer should have now, typically after each event or burst. Without it an item waits for the nextsendto fill the batch, which for an idle producer is not a bounded wait, and the consumer blocked inrecvcannot tell that from a producer with nothing to say.Sink::pending()reports how many items are queued.
flush on an empty batch returns at once, and on the ring costs nothing beyond
that pool wait, so a producer unsure which path it is on can simply flush after
every item.
Ending a stream
end() finishes the stream: on the ring it writes the end record, for which
room is always reserved, so it waits for the consumer only for a record a
dropped send_async left on the pool (see Async); over RPC it
flushes first, and that flush blocks for credit. end_with(&status) does the
same and hands the consumer an error — the failure the starting method could
have returned, arriving late because that method had already succeeded:
#![allow(unused)] fn main() { sink.end_with(&rsbinder::Status::new_service_specific_error( QUOTA_EXCEEDED, Some("daily quota reached".into()), ))?; }
On the other side the iterator yields that Status as its last Err, with the
service-specific code and message
intact, and then ends. Receiver::end_status() has the final status afterwards,
including a clean one, and is None until the end has been read: a death
notice or terminator that lands while items remain is not yet the end, and
those items come out first. On the ring the message is cut to what the end reserve
holds (244 bytes).
Call end on every path. A Sink that is dropped without it still ends the
stream, but as EX_ILLEGAL_STATE with a message saying how many items were
lost — from the consumer's side that is all a producer that vanished can mean.
The drop does not wait for the consumer, since a Drop that blocks would hold
whatever thread is unwinding: over RPC the items that cannot leave without
credit are lost, on the ring a record a dropped send_async future left
waiting for room is given up.
Cancelling, and a peer that dies
Receiver::cancel() tells the producer to stop, and dropping the receiver sends
it for you. A parked producer is released; its send returns
StatusCode::InvalidOperation and Sink::is_canceled() is what tells that apart
from a transport problem. From then on every send returns it at once without
taking the item, on both paths. end still sends the terminator it was given,
without reporting a loss: over RPC the items still queued are dropped. On the
ring the producer sees the cancel before
its next write, or in the wait it is parked in; over RPC a cancel made before
the producer has introduced itself is held and delivered the moment onStart
arrives, and the producer sees it at its next send.
Back-pressure means that most of the time neither side has a call in flight, so
nothing would fail by itself if the other process went away. Each end therefore
watches the other's binder — the producer the endpoint's sink, the consumer
the peer it was made against: a producer parked for room or credit gets
StatusCode::DeadObject from send, and a consumer blocked in recv gets the
same error as the stream's last item. A peer that is not in the producer's
process leaves the producer's death unseen, and a local object is not watched
at all. Over RPC, a session that ends is a death in the same sense.
Death does not take back what already arrived: the consumer reads every item
already in the ring or in a delivered batch before it sees the death, and an
end that reached it first — an end record already in the ring, an onEnd
already received — is the end the stream reports, not DeadObject. Once the
producer knows the consumer is dead, every send returns DeadObject at once
without taking the item, on both paths, and so does end.
A peer that goes silent
Over RPC the session has to notice the loss first. It does when a connection
fails, when the peer's host stops acknowledging (TCP keepalive — see
Timeouts), or when a reply is overdue, and a
waiting stream has no reply outstanding: every stream method is oneway.
Behind a relay that terminates TCP (adb forward, ssh -L) a peer can vanish
with none of the three happening, and the stream would wait for good; vsock
has no keepalive either.
So a waiting end pings. When a recv, or a producer's wait for credit, has
been woken by nothing for a third of the session's reply deadline, it sends
the other end a PING_TRANSACTION — the twoway every binder object answers,
so a C++ or NDK peer needs no code for it — and waits again once it is
answered. The consumer pings the producer's IStreamSource once onStart has
named it, the producer pings the sink. A ring over a Unix session pings the
same way (its consumer pings the binder it was made against), as does a
producer parked on a full one; a ring over kernel binder never pings, since
the kernel reports a death itself. An unanswered ping ends the session
through the reply deadline, like any unanswered call, and the stream then ends
with DeadObject through its death link: a peer that vanished is noticed
within about four thirds of the deadline. A stream whose items flow never
pings.
The deadline is the session's: RpcSession::set_timeout or
ClientOptions::timeout on a client, RpcServer::set_reply_timeout or
ServeOptions::reply_timeout on a server. A session without one — the
default — never pings, and PingPolicy::Off in either policy turns it off for
that end. The ping is not counted against recv_timeout or send_timeout, so
a call whose peer stops answering returns later than its own bound, by up to
the deadline. An *_async wait that may ping waits on the blocking pool (see
below).
Async
Both ends have tokio counterparts, behind the default tokio feature. Each
of them makes its wait from the blocking pool — a futex wait on the ring, a
oneway send over RPC — so poll them inside a Tokio runtime context:
spawn_blocking panics outside one. (A call made from inside a transaction
handler runs on the calling thread and is exempt.) A futex cannot be polled,
so on the ring each wait holds one pool thread for as long as it lasts, the
same cost as a thread of its own.
The consumer awaits recv_async(). There is no futures::Stream impl — that
would tie rsbinder's public API to a futures-core major version — so adapt it
where you need one:
#![allow(unused)] fn main() { while let Some(line) = lines.recv_async().await { handle(line?).await; } }
The producer uses send_async, flush_async, end_async and end_with_async
from a task. On calls, waiting for the consumer suspends the task instead of
parking a thread — except a wait that may ping or
has a send_timeout, which holds a pool thread while it lasts, as does a
recv_async that may ping: there is no timer to suspend a task against. On the
ring every such wait holds a pool thread, as above.
#![allow(unused)] fn main() { let mut sink = Sink::open(endpoint)?; tokio::spawn(async move { while let Some(line) = source.next().await { if sink.send_async(&line).await.is_err() { return; } } let _ = sink.end_async().await; }); }
send_async encodes the item when it is called rather than when it is first
polled, so the future does not borrow the item and is Send whatever the item
type is. send_async, flush_async and recv_async are cancel-safe: a future
dropped mid-wait loses no credit, and no item without the terminator saying so.
A record or batch already handed to the blocking pool still goes out, in its
place, and the next call waits for it first; a send that then fails is not
forgotten with the future — the next call that sends (flush_async, or a
send/send_async that has to wait) returns that failure, or the terminator
does, and the terminator counts those items as lost. On the ring, a
send_async future dropped while it still waits for such an earlier record has
handed nothing over: its item is dropped, and the terminator counts it as lost
too. The two terminators are not waits to abandon — a dropped
end_async or end_with_async leaves the stream to Drop, which ends it as
EX_ILLEGAL_STATE without waiting for the consumer, so whatever was still
queued is reported lost.
Over RPC
Each end calls the other from outside any handler — the producer sends batches
from a thread of its own, the consumer sends grants and cancel — and each end
watches the other for death. Even the producer's onStart, sent from inside
the handler of a download, is oneway and so never rides the connection the
request came in on. A default RPC session can do none of this toward the
client: nothing on the client reads its connection except inside its own calls.
The client has to open an incoming connection, which takes the android-13+
profile — see
Callbacks outside a handler:
#![allow(unused)] fn main() { use rsbinder::entry::{WireProfile, WireVersion}; let uri = rsbinder::Uri::new(rsbinder::Endpoint::unix("/run/demo.sock")) .with_wire(WireProfile::Android13Plus(WireVersion::MAX)); let client = rsbinder::Client::open_with(uri, |o, _| o.incoming_connections = Some(1))?; }
Without one, the client's end refuses with StatusCode::InvalidOperation
before any call is made — Receiver::new when the client consumes,
Sink::open when it produces — and logs which option is missing. Client::caps() reports
TransportCaps::CALLBACKS when the session can carry a stream. A session with
incoming connections has to be ended explicitly, with
client.session().unwrap().close_session(): its serving threads keep it
alive after every handle is dropped.
One more thing to size: the consumer's grants, and both ends' pings, go out on
the session's outgoing connections, and each waits for a free one. A client that keeps its only
outgoing connection busy with a long twoway call delays its own grants for that
long; give such a client a second one with outgoing_connections.
A ring over a Unix session
When both ends are on one host and talk over a Unix socket, the consumer can have the stream run on a ring instead of calls:
#![allow(unused)] fn main() { use rsbinder::stream::{Receiver, ReceiverPolicy, RingUse}; let policy = ReceiverPolicy { ring_use: RingUse::AlsoUnixRpc, ..Default::default() }; let (mut rx, endpoint) = Receiver::<LogLine>::with_policy(&service.as_binder(), &policy)?; if !rx.uses_ring() { // The session could not carry one; the stream runs on calls as usual. } }
The ring's memfd crosses the socket as an SCM_RIGHTS file descriptor, so the
session has to pass fds both ways: the client opens with
ClientOptions::fd_mode = Some(FileDescriptorTransportMode::Unix) and the
server allows it (ServeOptions::fd_modes or
RpcServer::set_supported_fd_modes). It also needs the incoming connection
above. A session that cannot pass the fd — vsock, TCP, TLS, no fd mode — gets
calls, and a log::debug! line names what was missing; uses_ring tells which
one a stream got. A session with no incoming connection refuses the stream with
InvalidOperation, opted in or not, as it does on calls. The ring needs Linux or
Android.
Across SELinux domains the ring needs what it needs over binder — fd use and
memfd_file { getattr read write map } on the consumer's domain, see
A minimal stream — plus what the session
needs: unix_stream_socket connectto on the server's domain, and for a socket
bound to a path, sock_file write on that file's type. An abstract socket
(RpcServer::setup_unix_server_abstract) has no file to check.
Which to choose is a matter of item size. Measured on x86-64 over a Unix
session, calls with 16 KiB batches were faster for items up to 128 bytes
(2.6 times for i32, 1.6 times at 64 bytes, 1.2 times at 128), where the ring's
per-record work outweighs a batched call; from 192 bytes on the ring was faster,
1.5 times at 256 bytes, 3 times at a kilobyte and over 4 times at 16 KiB. From
128 bytes on the ring also used less CPU. On an arm64 Android emulator the ring
was as fast as calls for i32 and faster above.
The producer has to implement the ring over RPC; rsbinder's does, and nothing
changes on its side. A producer written to the RPC-only contract refuses the
endpoint, and how that shows depends on the direction: in a download its
refusal is the error of the opening call; in an upload the opening call has
already returned the endpoint, so the consumer learns of it only from the
producer's death or its own recv_timeout. A producer that ignores the ring and
sends batches ends the stream with EX_ILLEGAL_STATE.
The items do not cross the socket, so to the session a flowing ring looks quiet.
The server's end therefore counts its ring traffic toward the session's idle
check (RpcServer::set_idle_timeout); a client has no idle check to feed. A
stream whose items keep coming, or come more often than the timeout, keeps the
session alive. Each wait on the ring that parks counts too, so a server-side end
that keeps waiting with a recv_timeout or send_timeout longer than the ring's
spin (20 µs) and shorter than the idle timeout keeps the session alive with no
item moving, where on calls it would not. A stalled one whose
waits outlast the timeout does not, as on calls, unless it pings.
State, not a sequence: use a oneway callback
A temperature, a progress percentage, a connection state: the consumer wants the current value and a stale one is worthless. That is a different problem, and back-pressure is the wrong tool for it. It exists because items cannot be dropped, so a slow consumer has to be able to stop the producer. When values can be dropped, nothing has to stop anybody — provided the handler that receives them never does slow work:
interface ITemperatureListener {
oneway void onChanged(float celsius);
}
#![allow(unused)] fn main() { struct Listener(tokio::sync::watch::Sender<f32>); impl ITemperatureListener for Listener { fn r#onChanged(&self, celsius: f32) -> rsbinder::BinderResult<()> { self.0.send_replace(celsius); // overwrite and return Ok(()) } } }
The handler overwrites one slot and returns, so the queue is one value deep no
matter how slow the code reading the watch::Receiver is, and the transaction
buffers are freed as fast as they arrive. "The consumer is slow" means its
processing is slow, not its binder handler. oneway calls to one object arrive
in order, so the last value written is the latest one. This is the ordinary
callback pattern, and it is what AOSP's own
state listeners do.
Two refinements, for when they apply:
- A slow link, where sending every value is itself the cost: put the same
watchon the producer side, and have one task send the current value, wait for that call to finish, and read the slot again. While the link is backed up the slot is simply overwritten, and the transport's own back-pressure paces the sender. - A frozen Android app as the listener: the kernel can replace a queued
onewaytransaction with a newer one to the same method, which keeps a cached app's buffer from filling up. It does so only when the call carriesFLAG_UPDATE_TXN, which a generated proxy never sets — send that one call by hand withsubmit_transact, asexample-hello/src/bin/update_txn_interop_client.rsdoes.
A C++ or NDK peer
A Java peer is out of scope: AOSP has no Java FMQ.
Compile the three .aidl files from rsbinder/aidl/stream/ with the
platform's aidl — StreamEndpoint.aidl imports
android.hardware.common.fmq.MQDescriptor, so the platform's FMQ AIDL has to be
on the include path — and implement the side you need. The SDK build-tools
aidl does not parse the @FixedSize T in the current FMQ files; include the
frozen V1 API instead
(hardware/interfaces/common/fmq/aidl/aidl_api/android.hardware.common.fmq/1
and hardware/interfaces/common/aidl/aidl_api/android.hardware.common/1),
which has the same wire. Which wire you have to
speak is decided by the endpoint you receive or make: a ring on kernel
binder, a sink alone over RPC.
The generated StreamEndpoint is a class template, and the service's methods
take the instance the .aidl names — StreamEndpoint<LogLine>, or
StreamEndpoint<int32_t> for StreamEndpoint<int>. The C++ compiler checks
that much. It does not check the items: they are bytes a peer encodes and
decodes itself, so reading them as the type argument names is up to the peer.
Over kernel binder the contract is the ring, and StreamEndpoint.aidl is
its specification: attach the MQDescriptor with libfmq
(AidlMessageQueue<int8_t, SynchronizedReadWrite>), read or write whole
records — 4-byte little-endian header, top bit the kind, low 31 bits the
payload length — and use the EventFlag bits as documented there. A producer
keeps the last 256 bytes free of item records so the end record always fits,
and waits with the mask NOT_FULL | CANCEL rather than writeBlocking, which
would not see the cancel. A consumer making the endpoint allocates the ring
itself and puts a binder in sink for the producer to link to for death; no
IStreamSink method is called on it. What else a libfmq peer must do, with
the details in StreamEndpoint.aidl:
- Attach with
AidlMessageQueue(desc, /*resetPointers=*/false): the other end may already have written. - Wake the other side after every read and every write, as
libfmqitself does, not only on a full-to-not-full change: a waiter may need room for more than one record. A read that takes several records at once may wake once for all of them, as rsbinder's consumer does. - As the producer, load
getEventFlagWord()before each item record and stop onCANCEL(libfmqhas no peek), and remember a cancel your wait returned: the wait consumes the bit. - Treat the ring as the other process's memory. A producer applies the checks rsbinder applies before it maps (a memfd sealed against shrinking or an ashmem region, an EventFlag word, a size bound, a capacity above 260 bytes); a consumer checks each record's length against the ring before reading it.
An NDK peer cannot link libfmq — it is not part of the NDK, and the device's
libfmq.so is built against the platform's C++ library rather than the NDK's.
Two header-only C11 files do that part instead:
rsbinder-fmq/c/rsbinder_fmq.h is the ring (attach with the same checks
rsbinder makes, create a sealed memfd, the counters, the EventFlag futex), and
contrib/ndk/rsbinder_stream.h is the record layer on it (rsbs_send,
rsbs_end, rsbs_recv, rsbs_cancel). The NDK compiles them with no extra
flag; on a 32-bit glibc host they need -D_FILE_OFFSET_BITS=64, without which
the queue header stops with #error. For death, create the
AIBinder_DeathRecipient with rsbs_on_binder_died, set
rsbs_on_unlinked as its onUnlinked (API 33), and link with the stream's
rsbs_*_death_cookie: libbinder can still be running the death callback after
AIBinder_unlinkToDeath returns, and the cookie keeps what it touches mapped
until onUnlinked, so the stream may be closed at any time. Each header has two
C++ templates that convert to and from the NDK backend's generated
MQDescriptor and StreamEndpoint.
example-hello/cpp/stream_interop.cpp is a complete client on the two headers
for both directions, example-hello/cpp/stream_libfmq_interop.cpp the same
client with its ring on libfmq, and run_stream_interop.sh runs both against
rsbinder on an emulator.
Over RPC the contract is the two interfaces, and the client opens incoming
connections as above (libbinder's
ARpcSession_setMaxIncomingThreads). example-hello/cpp/stream_rpc_interop.cpp
implements both ends on the device's libbinder RpcSession, and
run_stream_rpc_interop.sh runs it against rsbinder:
- As the consumer, implement
IStreamSink. InonStart, turn the binder into the interface (IStreamSource::asInterface, NDKIStreamSource::fromBinder) and link to its death. InonBatch, put the bytes into a parcel — C++Parcel::setData, NDKAParcel_unmarshalfollowed byAParcel_setDataPosition(p, 0)— and read the item typecounttimes. Callrequest(total)as you drain, wheretotalis how many batches you have drained since the stream began; if the call fails, call it again later with whatever the total is by then. The producer opens with thecreditsthatonStartstates, so the first grant can follow the first batch. To bound your queue, hold the producer tocreditsplus the highest total you have sent or tried to send, and decide inonStarthow large acreditsyou accept. - As the producer, implement
IStreamSourceand count: eachonBatchspends one credit,onEndspends none, and aftercancelonlyonEndmay still be sent.request(total)is worthtotalminus the highest total you have seen, and nothing when that is not positive — keep the highest, not a sum. Open with at least one credit of your own, and state it inonStart. An rsbinder consumer grants only for batches it has already drained, so a producer that starts at zero waits for a grant that nothing can trigger — and it ends the stream on a batch sent beyond the stated window plus its grants, or on a window wider than it accepts (4 unless it was made with a largerReceiverPolicy::max_opening). A batch sent beforeonStarthas no credit and ends the stream withEX_ILLEGAL_STATE, a negativecountwithEX_ILLEGAL_ARGUMENT. An rsbinder consumer sendscancelwhen its receiver is dropped, also after a clean end, so ignore acancelthat arrives after youronEnd. If anonBatchfails in a way that does not tell you whether it arrived, stop sending batches and sendonEnd: you no longer know how much credit you have.onEnd's three arguments are the fields of abinder::Status;-129,-127and-128are never sent.
Error Handling
Binder IPC introduces failure modes that do not exist in ordinary function calls:
the remote process may crash, the kernel driver may reject a transaction, or the
service may intentionally signal an application-level error. rsbinder represents
all of these cases through two complementary types -- StatusCode for
transport-level errors and Status for richer application-level errors that
include exception codes and optional messages.
Core Types
rsbinder::BinderResult<T>
Every AIDL-generated method returns this type:
#![allow(unused)] fn main() { pub type BinderResult<T> = std::result::Result<T, Status>; }
This is a standard Result whose error variant is a Status, and BinderResult is the name you will see in your generated files.
Note — two
Resultaliases coexist inrsbinder. AIDL methods usersbinder::BinderResult<T> = Result<T, Status>(rich error with exception code + optional message), but several library-level APIs (Interface::dump,hub::default,hub::register_for_notifications,hub::get_service_debug_info) use the plainrsbinder::Result<T>= Result<T, StatusCode>. BecauseStatusCodeimplementsFrom<StatusCode> for Status, propagating with?from aResult<_, StatusCode>into an AIDL method body (which returnsResult<_, Status>) works transparently.
StatusCode
StatusCode represents low-level transport errors that occur before or during
a Binder transaction. It is named rsbinder::StatusCode.
Commonly encountered values:
| Variant | Meaning |
|---|---|
Ok | Operation completed successfully |
Unknown | An unspecified error occurred |
BadValue | Invalid parameter value |
UnknownTransaction | The transaction code is not recognized |
PermissionDenied | Caller does not have permission |
DeadObject | The remote process has died |
FailedTransaction | The transaction could not be completed |
NoMemory | Out of memory |
BadType | Wrong data type encountered |
NotEnoughData | The parcel did not contain enough data |
A StatusCode can be converted directly into a Status:
#![allow(unused)] fn main() { let status: rsbinder::Status = rsbinder::StatusCode::PermissionDenied.into(); }
Status
Status combines three pieces of information:
- Exception code (
ExceptionCode) -- categorizes the error (e.g.ServiceSpecific,Security,NullPointer). - Status code (
StatusCode) -- provides transport-level detail. - Message (
Option<String>) -- an optional human-readable description.
Key methods on Status:
#![allow(unused)] fn main() { // Check the category of the error status.exception_code() // -> ExceptionCode // Get the transport error (only meaningful when exception is TransactionFailed) status.transaction_error() // -> StatusCode // Get the service-specific error code (only meaningful when exception is ServiceSpecific) status.service_specific_error() // -> i32 // The same code as your own type (see "Typed Error Codes" below) status.service_error::<LookupError>() // -> Option<LookupError> // The message the sender attached, if any status.message() // -> Option<&str> // Check if the status represents success status.is_ok() // -> bool }
ExceptionCode
ExceptionCode classifies the kind of error:
| Variant | Wire value | Meaning |
|---|---|---|
None | 0 | No error |
Security | -1 | Security / permission violation |
BadParcelable | -2 | Malformed parcelable data |
IllegalArgument | -3 | Invalid argument provided |
NullPointer | -4 | Unexpected null value |
IllegalState | -5 | Operation invalid for current state |
NetworkMainThread | -6 | Network operation on main thread (Java compatibility) |
UnsupportedOperation | -7 | Requested operation is not supported |
ServiceSpecific | -8 | Application-defined error with custom code |
Parcelable | -9 | Embedded user parcelable exception (Java compatibility) |
HasNotedAppOpsReplyHeader | -127 | Internal: reply parcel carries an AppOps noted-ops header |
HasReplyHeader | -128 | Internal: reply parcel carries a header (Java-specific marker) |
TransactionFailed | -129 | Low-level transaction failure |
JustError | -256 | Generic error fallback used internally by the library |
Returning Errors from a Service
Service-Specific Errors
The most common way for a service to report an application-level error is
through Status::new_service_specific_error. This sets the exception code to
ServiceSpecific and carries an integer error code whose meaning is defined
by the service:
#![allow(unused)] fn main() { fn ThrowServiceException( &self, code: i32, ) -> rsbinder::BinderResult<()> { Err(rsbinder::Status::new_service_specific_error(code, None)) } }
You can also attach an optional message:
#![allow(unused)] fn main() { Err(rsbinder::Status::new_service_specific_error( -1, Some("resource not found".into()), )) }
Typed Error Codes
An i32 is what crosses the wire, but it does not have to be what your code
handles. A type that implements rsbinder::ServiceSpecificError carries the
mapping once, so neither end writes a match over bare numbers:
#![allow(unused)] fn main() { // A plain Rust enum (needs the `macros` feature): #[derive(rsbinder::ServiceSpecificError, Clone, Copy, PartialEq, Eq, Debug)] #[repr(i32)] pub enum LookupError { NotFound = 1, Busy = 2, } fn lookup(&self, key: &str) -> rsbinder::BinderResult<i32> { Err(rsbinder::Status::service_specific( LookupError::NotFound, Some("no such key"), )) } }
For an enum that comes from .aidl, the generated type is an open newtype
rather than a Rust enum, so attach the trait with a macro instead:
#![allow(unused)] fn main() { rsbinder::impl_service_specific_error!(my_package::LookupError::LookupError); }
Either way the bytes are the ones AOSP's Status::fromServiceSpecificError
produces — a C++ or Java peer reads the same code it always did. The code
space is i32, so an .aidl enum backed by long cannot be an error type;
attaching the trait to one is a compile error rather than a value that
truncates on the wire.
Unimplemented Methods
When a service does not support a particular transaction (for example, a method
added in a newer version of the AIDL interface), return UnknownTransaction:
#![allow(unused)] fn main() { fn UnimplementedMethod( &self, _arg: i32, ) -> rsbinder::BinderResult<i32> { // Indicate that this method is not implemented Err(rsbinder::StatusCode::UnknownTransaction.into()) } }
The .into() conversion automatically creates a Status with the
TransactionFailed exception code.
Permission Errors
If your service enforces access control, return PermissionDenied:
#![allow(unused)] fn main() { fn restricted_operation(&self) -> rsbinder::BinderResult<()> { let caller = rsbinder::thread_state::CallingContext::default(); if caller.uid != ALLOWED_UID { return Err(rsbinder::StatusCode::PermissionDenied.into()); } Ok(()) } }
Handling Errors on the Client Side
Checking for Service-Specific Errors
When calling a service method, always check the Result for errors.
For service-specific errors, inspect the exception_code() first, then
retrieve the integer code:
#![allow(unused)] fn main() { let result = service.ThrowServiceException(-1); assert!(result.is_err()); let status = result.unwrap_err(); assert_eq!( status.exception_code(), rsbinder::ExceptionCode::ServiceSpecific, ); assert_eq!(status.service_specific_error(), -1); }
With a typed error the two checks collapse into one, because
service_error::<T>() answers None unless the status really is a
service-specific failure carrying one of T's values:
#![allow(unused)] fn main() { let status = service.lookup("k").unwrap_err(); match status.service_error::<LookupError>() { Some(LookupError::NotFound) => { /* create it */ } Some(LookupError::Busy) => { /* retry later */ } // Also reached for a code this build does not know: a peer compiled // against a newer contract sends one, and `service_specific_error()` // still has the raw number to log or forward. _ => log::warn!("unhandled: {status}"), } }
Distinguishing Error Categories
A practical error-handling pattern checks the exception code to determine how to react:
#![allow(unused)] fn main() { match service.some_method() { Ok(value) => { // Success } Err(status) => { match status.exception_code() { rsbinder::ExceptionCode::ServiceSpecific => { let code = status.service_specific_error(); eprintln!("Service error {code}: {status}"); } rsbinder::ExceptionCode::TransactionFailed => { let transport_err = status.transaction_error(); eprintln!("Transport error: {transport_err:?}"); } rsbinder::ExceptionCode::Security => { eprintln!("Permission denied: {status}"); } other => { eprintln!("Unexpected error ({other}): {status}"); } } } } }
Detecting a Dead Service
If the remote process crashes, methods will fail with DeadObject:
#![allow(unused)] fn main() { let result = service.some_method(); if let Err(ref status) = result { if status.transaction_error() == rsbinder::StatusCode::DeadObject { eprintln!("Service has died, attempting reconnection..."); } } }
DeadObject alone does not prove the service died — a live handler of an
in-process service, or of a service not built on rsbinder, can return it too
(rsbinder sends its own remote handlers' DeadObject as FailedTransaction)
— and over RPC the call that ended a session can return TimedOut instead. Reconnecting watches the service
and reconnects for you.
Converting Between Error Types
rsbinder provides From implementations that make conversions between
StatusCode, ExceptionCode, and Status straightforward:
#![allow(unused)] fn main() { // StatusCode -> Status let status: rsbinder::Status = rsbinder::StatusCode::BadValue.into(); // ExceptionCode -> Status let status: rsbinder::Status = rsbinder::ExceptionCode::IllegalArgument.into(); // Status -> StatusCode: the code the status carries, or `FailedTransaction` // for an exception that carries none. A status built from // `ExceptionCode::ServiceSpecific` carries `ServiceSpecific(0)`. let code: rsbinder::StatusCode = status.into(); }
These conversions are used most often in the Err(...) return position of
service methods, where .into() converts a StatusCode into the expected
Status type automatically.
Tips
-
Split the two kinds of failure.
Status::new_service_specific_erroris for what the caller's own logic cares about — "item not found", "quota exceeded" — with the codes declared as constants both sides share. A rawStatusCodelikePermissionDeniedorBadValueis for the IPC mechanism failing. -
Check
exception_code()before anything else.service_specific_error()andtransaction_error()are only meaningful for their own exception code; called on any other, they return zero rather than an error. -
Expect
DeadObjectin a long-running client. A service can restart.link_to_deathtells you when, so you can reconnect instead of failing every subsequent call — or letReconnectingdo it. -
StatusimplementsDisplayandstd::error::Error, so it works with?in a function returningBox<dyn Error>and prints usefully in a log.
Reconnecting to a Service
A service can go away: its process crashes or restarts, or — over RPC — its
session ends because one connection failed, a reply deadline expired or the
server closed it. Every proxy to it then returns DeadObject, and getting
back means looking the service up again, on a new session for RPC, and
registering your callbacks again. rsbinder::Reconnecting does that for one
service.
use rsbinder::{Endpoint, Reconnecting};
let listener = listener.clone();
let hello = Reconnecting::<dyn IHello>::builder(Endpoint::unix("/run/hello.sock").with_service("hello"))
.on_connect(move |conn| {
// Every (re)connection, before callers get the new proxy.
conn.proxy().register_listener(&listener)?;
Ok(())
})
.build()?;
let reply = hello.with(|h| h.echo("hi"))?;
The Uri is any the entry API takes: a
kernel endpoint with a service name (Uri::kernel().with_service(name),
binder://name) for the service manager, or an RPC endpoint with one
(#name). Leave the service name out to connect to a server's root object — the form a libbinder
RpcServer::setRootObject server needs, since only an rsbinder server has a
directory of names.
What happens when the service goes away
The helper notices, starts a thread of its own, looks the service up again
— backing off from 10 ms to at most 1 s between failed attempts — runs
on_connect, and only then hands the new proxy to callers. generation()
counts the connections made.
How it notices depends on what the lookup returned:
| Looked-up service | Noticed by |
|---|---|
| a kernel binder proxy | its death notification, at once |
| RPC, the session has incoming connections or a serve loop | its death notification, as the session ends |
RPC otherwise — the default unix://…#name | the next call: before it is sent where the socket reports the close (Unix sockets; TCP on Linux/Android), otherwise after that call fails |
| a service in the same process | never: it cannot die on its own |
It never decides from a status code. The call that ended a session can
return TimedOut, and a live server's handler can return DeadObject.
Calls while it reconnects
with runs your closure once and returns its result. A failed call is never
sent again: only the service knows whether a call may run twice.
With no connection, with returns DeadObject — but if a reconnect attempt
is in progress it first waits for it, up to ReconnectPolicy::call_wait
(1 s). A server that is already back answers an attempt within milliseconds,
so the call runs on the new connection. Between attempts — the server still
down — calls fail at once instead of each waiting a second. Inside a binder
transaction or inside on_connect nothing waits: the reconnect may be
waiting on that very thread.
To wait across attempts, use wait_connected(timeout), or
connected().await with tokio. current() returns the proxy without
waiting, for async code that must not block.
Starting before the server
build() tries once without waiting. It fails only for what another
attempt cannot change — a Uri that serve / connect would refuse,
options that do not fit it, a missing feature, a kernel endpoint without a
service name, no binder device for a kernel endpoint (NoInit), a kernel
service of another interface (BadType), an RPC name lookup on a server
without a directory of names (BadType), a name the service manager
refuses to watch, or
ConnectError::stop from on_connect. A server that is not up yet is not
an error: the helper connects in the background, so a client may start
first. Set ClientOptions::timeout through options(...) if the server
could accept a connection and never answer.
on_connect
Register callbacks, subscribe, restore what the server lost. Return
Err(ConnectError::stop(code)) to give up for good; anything else —
including a ? on a call that failed — counts as a failed attempt and is
retried. A callback the server makes while the hook runs should use
conn.proxy(): the helper hands out the new proxy only after the hook
returns.
Lifetime
Dropping the Reconnecting (or close()) closes the current RPC session
and stops any reconnect in progress after its current step, without waiting
for it. A callback object that needs the helper should hold
helper.downgrade() — a WeakReconnecting — not the helper: a strong
handle there keeps the helper, its session and the callback alive in a
cycle.
The helper does not register services. A service that outlives a restarted
service manager must add itself again; on Android, init restarts the
services that depend on servicemanager.
Observability
Three pieces tell you what a binder service is doing and for whom:
| Piece | What it answers | Android counterpart |
|---|---|---|
| Work source | On whose behalf is this call being made? | IPCThreadState::setCallingWorkSourceUid, Java Binder.setCallingWorkSourceUid |
| Transaction names | Which AIDL method is transaction code 7? | aidl --trace |
| Transaction observers | What did every incoming call cost, and did it fail? | Java Binder.setObserver / BinderCallsStats |
None of them changes the wire format. The work source rides in a field every kernel binder request header already has; names and observers are local to the process. All three are new in 0.12.0.
Work source
A service that does work for someone else — a media server decoding for an app, a proxy forwarding a request — can say so. The work source is a uid attached to outgoing calls, separate from the calling uid the kernel reports, so the next service can attribute cost to the uid that asked for the work rather than to the process that forwarded it.
#![allow(unused)] fn main() { use rsbinder::{restore_calling_work_source, set_calling_work_source_uid}; let token = set_calling_work_source_uid(app_uid); let result = decoder.decode(&frame); // the request header carries app_uid restore_calling_work_source(token); }
On the receiving side the handler reads it:
#![allow(unused)] fn main() { fn decode(&self, frame: &Frame) -> rsbinder::status::Result<Image> { let attributed_to = rsbinder::get_calling_work_source_uid(); // u32::MAX: unset // ... } }
The rules follow AOSP:
- The work source is per thread. A client thread can set it outside any transaction, and every kernel binder call that thread makes carries it.
- A handler sees what its caller sent. A received value is not forwarded on the handler's own outgoing calls unless the handler sets it again, so a work source never travels further than the service that chose to pass it on.
- When the handler returns, the thread's previous value comes back; nothing a handler sets leaks into the next call served on that thread.
clear_calling_work_sourcesends "unset" explicitly,clear_propagate_work_sourcestops sending it, andshould_propagate_work_sourcereports which is in effect.
RPC has no work-source field — AOSP's RPC wire has none either. Inside an
RPC handler get_calling_work_source_uid reports the unset value; a value set
there still propagates to the kernel binder calls the handler makes. The
functions work in a pure-RPC process too.
Transaction names
Generated code identifies methods by transaction code. To let tools name them,
generate the interface with a name table, as AOSP does with aidl --trace:
#![allow(unused)] fn main() { // build.rs rsbinder_aidl::Builder::new() .source(PathBuf::from("aidl/IDecoder.aidl")) .trace(true) .output(PathBuf::from("decoder.rs")) .generate()?; }
The generated service then answers Remotable::transaction_name(code) with the
AIDL method name, including the stable-AIDL meta methods
getInterfaceVersion and getInterfaceHash. The table is off by default, as
in AOSP, because it costs one &'static str per method. Hand-written
Remotables and interfaces declared with the
interface macros keep the default, which returns
None.
With trace(true) the generated proxies also open a client-side span per call
when the tracing feature is on (see below).
Transaction observers
A TransactionObserver is called around every transaction the process serves,
on kernel binder and over RPC: on_transact before the handler runs and
on_reply after it returns, before the reply is sent.
#![allow(unused)] fn main() { use std::any::Any; use std::sync::Arc; use std::time::Duration; use rsbinder::observe::{set_observer, TransactionObserver, TxnContext}; struct SlowCallLog; impl TransactionObserver for SlowCallLog { fn on_transact(&self, _ctx: &TxnContext<'_>) -> Option<Box<dyn Any + Send>> { None } fn on_reply( &self, ctx: &TxnContext<'_>, _tag: Option<Box<dyn Any + Send>>, result: &rsbinder::Result<()>, elapsed: Duration, ) { if elapsed > Duration::from_millis(100) { log::warn!( "{} {} took {elapsed:?} for uid {}: {result:?}", ctx.descriptor, ctx.method.unwrap_or("?"), ctx.calling_uid, ); } } } set_observer(Some(Arc::new(SlowCallLog))); }
TxnContext carries the descriptor, the code, the method name (with
trace(true)), the one-way flag, the caller's uid and pid, and the transport
(TransportCaps::KERNEL or the RPC session's capabilities). Whatever
on_transact returns comes back to on_reply as tag — a start marker or a
span, for example.
What an implementation can rely on:
- Both calls run on the thread that serves the transaction, with no rsbinder lock held, so an observer may make binder calls of its own. Its time is added to the caller's latency.
- Every
on_transactis followed by exactly oneon_reply, one-way calls included. - A panic in either call is caught and logged; it does not change the transaction's result.
resultis the transport-level result. An AIDL method that returns an exception or a service-specific error writes it into the reply and still reportsOk(())here.- The kernel reports no sender pid for a one-way call, so
calling_pidis0there. - Over RPC, the session answers
INTERFACEandPINGitself, so those two meta transactions are observed on kernel binder only.
There is one observer per process, because kernel binder serves every
object from one process-wide thread pool. Filter on ctx.descriptor or
ctx.transport to watch a subset, and write an observer that forwards to
several to combine them. With none installed, a dispatch pays one atomic load.
Provided observers
-
LogObserverwrites onedebugline per transaction, targetrsbinder::observe. -
StatsObserverkeeps, per descriptor and code, the call count, transport-error count, total and maximum handler time, and a power-of-two microsecond latency histogram, plus how many transactions are being served at once and the peak of that number:#![allow(unused)] fn main() { use rsbinder::observe::{set_observer, StatsObserver}; let stats = Arc::new(StatsObserver::new()); set_observer(Some(stats.clone())); // ... serve ... let snapshot = stats.snapshot(); for m in &snapshot.methods { println!("{} {:?}: {} calls, max {:?}", m.descriptor, m.method, m.calls, m.max); } println!("peak concurrency {}", snapshot.max_in_flight); }A peak equal to the thread-pool size is a hint, not proof, that the pool ran out: a callback nested on the same thread counts twice.
Tracing spans
The tracing feature (off by default) produces spans named as AOSP names its
ATrace sections:
AIDL::rust::<descriptor>::<method>::server
AIDL::rust::<descriptor>::<method>::client
A code the name table does not cover is written #<code>, as in AOSP. Because
a tracing span name must be a &'static str, the string is the name field
of a span called aidl (target rsbinder::aidl, level TRACE).
- Server spans come from
observe::TracingObserver. Install it like any other observer; the span covers the handler, so spans the handler opens — including the client span of a nested AIDL call — are its children. - Client spans come from proxies generated with
trace(true). The span is created where the call is made, so its parent is the caller's current span, for the async proxy as well.
[dependencies]
rsbinder = { version = "0.12", features = ["tracing"] }
#![allow(unused)] fn main() { tracing_subscriber::fmt() .with_env_filter("rsbinder::aidl=trace") .init(); rsbinder::observe::set_observer(Some(Arc::new(rsbinder::observe::TracingObserver))); }
Without the feature the generated client hook is a plain function call, and
code generated without trace(true) is unchanged.
Service Manager (HUB)
The service manager is the central registry for Binder services. Every service
that wants to be discoverable by other processes registers itself with the
service manager under a well-known name, and every client that needs a service
looks it up by that name. In rsbinder, the service manager is referred to as
HUB and is accessed through the rsbinder::hub module.
On Linux, the HUB is provided by the rsb_hub binary that ships with the
rsbinder-tools crate. On Android, the system's native servicemanager
fulfills this role, and rsbinder talks to it using the same Binder protocol.
Note: This chapter covers the kernel-binder service manager. The RPC transport (binder-over-socket) uses no service manager at all —
RpcServer::set_rootpublishes the root binder directly andRpcSession::get_rootfetches it. The Android 16 Accessor pattern bridges the two: a kernel-binder service of typeIAccessorhands a client an RPC socket fd. See RPC Transport.
Running the Service Manager (Linux)
Before any service can register or any client can perform lookups, the HUB process must be running:
# Build and run the service manager, with no access control (development)
$ cargo run --bin rsb_hub -- --insecure-allow-all
rsb_hub opens the Binder device, becomes the context manager (handle 0),
and enters a loop that processes registration and lookup requests. It must
remain running for the lifetime of the system's Binder services.
It also refuses to start without an access-control policy. Every request
is denied unless a policy allows it, and --insecure-allow-all above is the
explicit opt-out for development. See Access control for
how to write one.
Running it under systemd
rsb_hub handles three signals: SIGHUP reloads the configuration in place,
and SIGTERM / SIGINT stop it cleanly — exiting 0 rather than dying by
signal, so a deliberate stop is distinguishable from a crash in the journal.
Use Type=notify. The HUB reports READY=1 only once it holds handle 0,
which is the first instant a client can reach it; with Type=simple systemd
would consider the unit started as soon as exec returned, and every unit
ordered After= it would race the registry.
[Unit]
Description=rsbinder service manager
After=dev-binderfs.mount
[Service]
Type=notify
ExecStart=/usr/bin/rsb_hub --config /etc/rsbinder/hub.d
ExecReload=/bin/kill -HUP $MAINPID
Restart=on-failure
[Install]
WantedBy=multi-user.target
A binder device has exactly one service manager — the kernel enforces that,
not rsb_hub — so a second instance on the same device exits 1 and says so.
To run an independent one, give it its own device: sudo rsb_device other
then rsb_hub --device other.
Inspecting a running HUB
The rsb_service CLI, also from rsbinder-tools, is the Linux counterpart of
Android's service and dumpsys -l:
$ rsb_service list # what is registered
$ rsb_service info # ... and which pid owns each
$ rsb_service check com.example.IFoo/default # is it up yet?
$ rsb_service declared com.example.IFoo/default # is it even configured?
$ rsb_service instances com.example.IFoo # every declared instance
$ rsb_service connection com.example.IFoo/default # declared ip:port, if any
$ rsb_service dump manager # rsb_hub's own registry
dump manager is the one with no Android equivalent — AOSP's
servicemanager does not implement dump at all, because on Android the
same questions are answered by dumpsys, service list and the init/VINTF
files:
rsb_hub 0.12.0
access control: enforcing, 4 rule(s)
declarations: 2
death subscriptions: 3
services (2):
com.example.IFoo/default
pid=92338 uid=973 dump_priority=0x8 lazy=no accessor=no
clients=no guarantee_client=no callbacks: registration=0 client=0
manager
pid=92334 uid=973 dump_priority=0x8 lazy=no accessor=no
clients=no guarantee_client=yes callbacks: registration=0 client=0
awaiting registration (1):
com.example.INope/default callbacks: registration=1 client=0
That last section is usually the one you came for: a name something is
waiting on that nothing has registered. Arguments narrow the listing by
substring (rsb_service dump manager com.example), and dump works on any
service, not just the HUB — it sends DUMP_TRANSACTION, which for an
rsbinder service lands in its Interface::dump implementation.
Exit status is the answer: 0 yes, 1 no, 2 the question could not be
answered (no service manager, or its policy denied it), so
rsb_service check foo || start-foo reads the way it looks. rsb_service is
an ordinary binder client, so the HUB's policy applies to it like anything
else — list and info need list, and every name they report is
filtered by find; dump <name> needs find on <name>, and for
manager also list.
Registering a Service
To make a service available to other processes, create a Binder object and
register it with hub::add_service:
#![allow(unused)] fn main() { use rsbinder::*; // Create the Binder service object let service = BnMyService::new_binder(MyServiceImpl); // Register it under a descriptive name. `add_service` accepts anything // convertible into `SIBinder`, so the typed `Strong<dyn IMyService>` can // be passed directly — no `.as_binder()` needed. hub::add_service("com.example.myservice", &service)?; }
The name passed to add_service is the identifier that clients will use to
find the service.
Registration Rules
The service manager enforces several constraints on service names. These
checks are applied by the service manager process (rsb_hub on Linux,
servicemanager on Android) — the rsbinder library itself does not validate
the name on the client side, so the failure surfaces as a Status error from
the IPC call rather than a local compile-time or pre-flight check.
- Maximum length: 127 characters. Names of 128 characters or longer are rejected.
- Allowed characters: Alphanumeric characters, dots (
.), underscores (_), hyphens (-), and forward slashes (/). Special characters such as$are not allowed. - Non-empty: An empty string is rejected.
- Overwrite rules: Re-registering the same binder under a name, or
replacing a registration from the same UID (e.g. a restarted service
process), is always allowed. However,
rsb_hubrejects an attempt by a different UID to overwrite an existing name with a different binder — a likely service-name hijack — unless it was started with the opt-in--allow-cross-uid-overwriteflag.
#![allow(unused)] fn main() { let service = BnFoo::new_binder(FooImpl {}); // Empty names are rejected assert!(hub::add_service("", &service).is_err()); // Valid name assert!(hub::add_service("foo", &service).is_ok()); // Maximum length (127 characters) let long_name = "a".repeat(127); assert!(hub::add_service(&long_name, &service).is_ok()); // Too long (128 characters) let too_long = "a".repeat(128); assert!(hub::add_service(&too_long, &service).is_err()); // Special characters are rejected assert!(hub::add_service("happy$foo$fo", &service).is_err()); }
Looking Up Services
rsbinder provides a family of lookup functions. They differ only in how they wait and how they encode "not registered" — pick by what your client needs:
| Function | Waits? | Returns | Not registered |
|---|---|---|---|
wait_for_interface::<T> | blocks until registered | Result<Strong<T>> | blocks |
wait_for_service | blocks until registered | Option<SIBinder> | blocks |
check_interface::<T> | no | Result<Strong<T>> | Err(NameNotFound) |
check_service | no | Option<SIBinder> | None |
try_get_interface::<T> | no | Result<Option<Strong<T>>> | Ok(None) |
try_get_service | no | Result<Option<SIBinder>> | Ok(None) |
The three awaitable ones live at the crate root rather than in hub, because only they
have to be awaited (the tokio feature, on by default):
| Function | Waits? | Returns | Not registered |
|---|---|---|---|
wait_for_interface_async::<T> | awaits until registered | Result<Strong<T>> | awaits |
check_interface_async::<T> | no | Result<Strong<T>> | Err(NameNotFound) |
get_interface_async::<T> | no | Result<Strong<T>> | Err(NameNotFound) |
Removed in 0.11.0:
hub::get_serviceandhub::get_interfaceare gone (deprecated in 0.10.0) because their wait behavior was inconsistent across Android versions.try_get_service/try_get_interfaceissue the samegetServicewire call; usewait_for_service/wait_for_interfaceto block until the service appears, orcheck_service/check_interfacefor a non-blocking lookup that does not start a lazy service — except on Android 15r6+ (theandroid_15protocol), wherecheckServicereturns a union rsbinder does not parse, socheck_serviceroutes throughgetServiceand does start one.
Blocking, Type-Safe Lookup with wait_for_interface
The most common client pattern is hub::wait_for_interface, which blocks
until the service is registered and casts it to the expected AIDL interface
type in one step — the equivalent of AOSP's waitForService:
#![allow(unused)] fn main() { let service: rsbinder::Strong<dyn IMyService::IMyService> = hub::wait_for_interface("com.example.myservice")?; // Now call methods directly on the typed proxy let result = service.some_method()?; }
The wait is unbounded: it returns when the service appears, and fails only
if the service manager itself is unreachable. The wait is event-driven
(registration-callback based) when the process runs a binder thread pool
(ProcessState::start_thread_pool()); without one it degrades gracefully to
~1-second polling.
The same wait from async code
rsbinder::wait_for_interface_async is the awaitable form — same contract, same
event-driven wake, and it can be given up on:
#![allow(unused)] fn main() { let service = tokio::time::timeout( Duration::from_secs(5), rsbinder::wait_for_interface_async::<dyn IMyService::IMyService>("com.example.myservice"), ).await??; }
timeout is tokio/time, which rsbinder's tokio feature does not enable;
declare it on your own tokio dependency.
Dropping the future (a timeout that expires, a select! arm that loses) ends the wait
and unregisters the callback it placed with the service manager. Do not hand-roll it as
spawn_blocking(hub::wait_for_interface): dropping that future only detaches the
JoinHandle, leaving a blocking-pool thread waiting for a service nobody is waiting for
any more.
Each outstanding wait holds one thread of the same spawn_blocking pool your outbound
calls go through, so this is for the handful of waits a process does at start-up — not for
polling a name in a loop.
hub::wait_for_service is the untyped variant — it returns
Option<SIBinder> when you need the raw handle (for example, to inspect the
descriptor or pass it to another API):
#![allow(unused)] fn main() { if let Some(binder) = hub::wait_for_service("com.example.myservice") { println!("Found service with descriptor: {}", binder.descriptor()); } }
Non-Blocking Check with check_service / check_interface
hub::check_service returns immediately, with None if the service is not
(yet) registered:
#![allow(unused)] fn main() { let binder: Option<SIBinder> = hub::check_service("com.example.myservice"); if binder.is_some() { println!("Service is available"); } else { println!("Service is not yet registered"); } }
The typed counterpart hub::check_interface casts in one step and returns
Err(StatusCode::NameNotFound) immediately when the service is absent.
Error-Preserving Lookup with try_get_service / try_get_interface
check_service folds "not registered" and "service manager unreachable"
into the same None. When you must tell those apart, use
hub::try_get_service (or the typed hub::try_get_interface), which
returns Result<Option<_>>: Ok(None) means the service is not registered,
while Err(..) means the lookup itself failed (transport or service-manager
error):
#![allow(unused)] fn main() { match hub::try_get_service("com.example.myservice") { Ok(Some(binder)) => println!("found: {}", binder.descriptor()), Ok(None) => println!("service not registered"), Err(err) => println!("service manager unreachable: {err:?}"), } }
Listing Registered Services
To enumerate all services currently registered with the HUB, use
hub::list_services with a dump priority flag:
#![allow(unused)] fn main() { let services = hub::list_services(hub::DUMP_FLAG_PRIORITY_DEFAULT); for name in &services { println!("Available: {}", name); } }
The dump priority flag filters which services are returned. The most commonly used flags are:
| Flag | Description |
|---|---|
DUMP_FLAG_PRIORITY_DEFAULT | Services with default priority |
DUMP_FLAG_PRIORITY_HIGH | High-priority services |
DUMP_FLAG_PRIORITY_CRITICAL | Critical system services |
DUMP_FLAG_PRIORITY_NORMAL | Normal-priority services |
DUMP_FLAG_PRIORITY_ALL | All services regardless of priority |
list_services reports an unreachable or unwilling service manager as an
empty list. When you need to tell "nothing is registered" from "the policy
denied this caller", use hub::try_list_services, which returns
Result<Vec<String>, Status> and preserves the EX_SECURITY status — and
its message — that rsb_hub sends on a denial. The same pairing exists for
is_declared / try_is_declared, get_declared_instances /
try_get_declared_instances, get_connection_info /
try_get_connection_info, and get_service_debug_info /
try_get_service_debug_info. This is exactly what rsb_service uses to
report a denial as a denial.
Service Notifications
You can register a callback that fires whenever a service with a particular name is registered (or re-registered). This is useful for clients that start before the service they depend on is available.
Defining a Callback
Implement the hub::IServiceCallback trait:
#![allow(unused)] fn main() { struct MyServiceCallback; impl rsbinder::Interface for MyServiceCallback {} impl hub::IServiceCallback for MyServiceCallback { fn onRegistration( &self, name: &str, service: &rsbinder::SIBinder, ) -> rsbinder::BinderResult<()> { println!("Service registered: {name}"); Ok(()) } } }
Registering and Unregistering
Wrap the callback in a Binder object and pass it to the HUB:
#![allow(unused)] fn main() { let callback = hub::BnServiceCallback::new_binder(MyServiceCallback); // Start receiving notifications hub::register_for_notifications("com.example.myservice", &callback)?; // Later, when notifications are no longer needed hub::unregister_for_notifications("com.example.myservice", &callback)?; }
The callback will be invoked each time a service matching the given name is registered, including if it is re-registered after a restart.
Thread pool required.
onRegistrationarrives as an inbound binder transaction, so it never fires unless the client is running one.
Checking if a Service is Declared
hub::is_declared checks whether a service name has been declared in the
system's service configuration (VINTF manifest on Android). This is distinct
from whether the service is currently running:
#![allow(unused)] fn main() { let declared = hub::is_declared("com.example.myservice"); if declared { println!("Service is declared in the manifest"); } else { println!("Service is not declared"); } }
On Linux with rsb_hub, this always returns false because rsb_hub
has no VINTF parser and no manifest concept (rsb_hub.rs
implements isDeclared as Ok(false)). On Android, it reflects the device's
hardware interface declarations.
Debug Information
For diagnostics, hub::get_service_debug_info returns metadata about every
registered service, including its name and the PID of the process hosting it:
#![allow(unused)] fn main() { let debug_info = hub::get_service_debug_info()?; for info in &debug_info { println!("Service: {} (pid: {})", info.name, info.debugPid); } }
The returned ServiceDebugInfo struct has two fields:
name(String) -- the registered service name.debugPid(i32) -- the PID of the process that registered the service.
This feature is available on Android 12 and above. On Android 10 and Android 11,
calling get_service_debug_info returns an error.
get_service_debug_info flattens the service manager's Status into a
StatusCode, which turns a policy denial into an anonymous
FailedTransaction. hub::try_get_service_debug_info keeps the Status,
message included.
Access control
rsb_hub gates every entry point on a policy. There is no permissive
default: a name with no matching rule is denied, a rule that does not mention
a permission denies it, and a policy that fails to load stops the process
from starting.
Why it does not use SELinux
Android's servicemanager gates the same three permissions through SELinux.
SELinux is not present on every Linux distribution and does not exist on
macOS, so it cannot be the basis of the model here — and it does not need to
be. Android's policy model is SELinux-independent:
(caller identity, service name) -> {add, find, list}
SELinux is only one way of expressing "caller identity". rsb_hub keeps the
model and swaps that identity for uid, which every platform reports and
the binder driver fills in itself (sender_euid) at transaction time —
unforgeable and not racy. This is the same conclusion D-Bus reached for the
same problem: uid/group policy files as the portable base, with an LSM as an
optional overlay rather than a prerequisite.
Note what is not used: pid. Pid reuse races make every pid-derived
attribute (/proc/<pid>/exe, cgroup, systemd unit) unsound as an
authorization key — which is why AOSP calls its own field debugPid. Groups
are usable because resolving uid → groups through NSS is race-free.
Permissions
| Permission | Gates |
|---|---|
add | add_service, register_client_callback, try_unregister_service |
find | check_service, wait_for_service, try_get_service, register_for_notifications, is_declared, get_connection_info, and the per-name filtering of the two list calls |
list | list_services, get_service_debug_info — a single global gate, since there is no name to scope it to |
A denied lookup is reported as "not registered" rather than as an error.
That matches AOSP (getService returns ok regardless of result) and keeps a
denied caller from using the error to probe which names exist. Every other
denial returns ExceptionCode::Security.
The two list calls apply both gates: the caller must be allowed to
enumerate at all, and each name it would learn about must be one it may
find. A name you cannot look up is a name you do not get told about.
Policy files
rsb_hub --config <PATH> takes a .toml file or a directory of them
(default: /etc/rsbinder/hub.d). A directory is read as every *.toml in
it whose name does not start with . (editor lock and backup files), sorted
by file name — so 10-/20- prefixes control precedence the way they do in
any other .d directory.
# /etc/rsbinder/hub.d/10-example.toml
[global]
# Who may enumerate service names at all. Omitted means nobody.
list = { group = ["binder-admin"] }
[[rule]]
name = "com.example.*" # trailing `*` only
add = { user = ["exampled"] }
find = { group = ["example-clients"], uid = [0] }
[[rule]]
name = "*"
add = "none"
find = "none"
A subject is the union of uid (numeric), user (resolved through NSS at
load time), and group (matched against the caller's primary and
supplementary groups). The shorthands "any" and "none" are also accepted;
"any" must be written out, since an omitted key always means deny.
The first rule whose name matches decides, and evaluation stops there —
a later rule never widens what an earlier one refused. That makes the rule
governing any given name unique and greppable, which is worth more in a
security file than the extra expressiveness of last-match-wins. Put
catch-alls last.
Patterns support a trailing * and nothing else. A leading or interior *
is rejected at load time rather than silently matching nothing.
Reloading
SIGHUP reloads the policy in place:
$ sudo kill -HUP "$(pidof rsb_hub)"
A reload that fails to parse or resolve keeps the policy already in force and logs the error. Dropping to deny-all would take the machine's IPC down over a typo; falling back to permissive would be worse. The reload also drops the memoized uid → groups sets, so group-membership changes take effect with it. A group added to a caller is also picked up without a reload — a call that a group rule denies re-reads the name service, at most once every 15 s per uid. A removed group keeps granting until the reload: a re-read only adds groups, because an answer read while a backend (e.g. sssd) is down can lack some and looks the same as a complete one.
Layers underneath
The policy is the second of two gates. The first is the binder device node
itself: binder has no in-kernel access control of its own, so
/dev/binderfs/<name> decides who can speak binder at all. rsb_device
creates it 0600 (root only) and takes --group / --mode to widen that
deliberately — the same model as /dev/kvm being 0660 root:kvm:
$ sudo groupadd -f binder
$ sudo usermod -aG binder "$USER"
$ sudo rsb_device binder --group binder --mode 0660
An LSM (SELinux, AppArmor) can sit on top as a third, AND-ed layer where the platform provides one, but it is never what makes the first two unnecessary.
Declaring services
An access rule says who may use a name. A declaration says the name is
expected to exist at all — the question is_declared answers, and the one a
client uses to tell "not installed" from "not started yet". On Android that
comes from VINTF manifests; on Linux it comes from a [[service]] entry in
the same configuration files.
[[service]]
name = "com.example.IFoo/default" # pack.age.IFace/instance
start = { systemd = "example-foo.service" }
connection = { ip = "127.0.0.1", port = 8080 }
A declaration answers three calls:
| Call | Answered from |
|---|---|
is_declared("com.example.IFoo/default") | the entry existing |
get_declared_instances("com.example.IFoo") | the instance halves of every entry for that interface |
get_connection_info("com.example.IFoo/default") | the connection table |
Declared instances are filtered by find, so a caller only learns about
instances it could look up.
Starting a service on demand
With a start entry, a try_get_service (or wait_for_interface) that misses
the service asks for it to be started — AOSP's tryStartService, with the
declaration standing in for the init property:
start = { systemd = "example-foo.service" }
# or
start = { exec = ["/usr/bin/exampled", "--instance", "default"] }
check_service never does this: it is documented as non-blocking and free
of side effects. Only the try_get_* / wait_* families start anything (on
Linux; see the Android 15 r6+ note above).
The start runs on its own thread and rsb_hub does not wait for it — what
tells the client the service is up is the registration notification it is
already waiting on, which is why wait_for_interface is the natural call
here. At most one start per name and per activation is outstanding at a
time — names that share one exec argv or one systemd unit share it, as
init runs one service for several interfaces, and a name whose start a
reload changed is not started again while its old start is outstanding — so
a client polling a service that cannot come up does not spawn a copy per
attempt. An exec start stays
outstanding for the process's lifetime. exec takes an argv list, never a
shell string.
The configuration is a trust boundary. A
startentry runs withrsb_hub's privileges, and any client allowed to look the name up can trigger it.rsb_hubtherefore refuses to start if the configuration, any file in it, or any directory on the way to them from/is writable by anyone but its owner, or is owned by someone other than root orrsb_hubitself — the same check sudo, ssh and cron apply to their own configuration. Symlinks are followed, and the directories holding them are held to the same rule; every inode is checked through the descriptor it is then read from. A world-writable directory such as/tmpfails even with the sticky bit. Keep it0644root-owned in a0755root-owned directory; for an unprivileged run, under$XDG_RUNTIME_DIRor$HOME.Every
execprogram (argv[0]) is held to the same rule: the program and every directory on the way to it from/. A program that fails the check, or does not exist, fails the load like an untrusted configuration file does. On Linux and Android the program needs execute permission only;rsb_hubdoes not have to be able to read it. Other OSes have noO_PATH, so there the check opens the program for reading and the program needs read permission as well. The check runs when the configuration is loaded and again right before each start: a start whose program fails it then is refused and logged, which also covers a program that a failedSIGHUPreload judged untrusted while the previous policy stays in force. The start executes the path after that check, so a replacement made between the two is not caught; a path that passed can be replaced only by root or byrsb_hub's own uid.
Linux vs. Android Differences
While rsbinder aims for API compatibility across both platforms, there are
important behavioral differences between the Linux HUB (rsb_hub) and
Android's native servicemanager:
| Aspect | Linux (rsb_hub) | Android (servicemanager) |
|---|---|---|
| Process | User-space rsb_hub binary | System servicemanager daemon |
| Access control | uid/group policy files (above) | SELinux MAC policy |
| Service declarations | [[service]] entries (above) | VINTF manifests |
| Service debug info | Supported | Supported (Android 12+; not on 10/11) |
| Binder device | Must be created with rsb_device | Managed by Android init |
| Version selection | Always uses Android 16 protocol | Auto-detected from SDK version |
| Death notifications | Supported | Supported |
| Diagnostics | rsb_service, and dump on the HUB itself | dumpsys / service; servicemanager has no dump |
| Readiness | sd_notify(READY=1) under Type=notify | servicemanager.ready property |
On Android, rsbinder automatically detects the SDK version and uses the appropriate service manager protocol (Android 10 through 16). The per-version API availability matrix:
| API | Android 10 | Android 11 | Android 12+ |
|---|---|---|---|
check_service / wait_for_service | ✓ | ✓ | ✓ |
add_service | ✓ | ✓ | ✓ |
list_services | ✓ | ✓ | ✓ |
is_declared | false | ✓ | ✓ |
register_for_notifications | ✗ | ✓ | ✓ |
unregister_for_notifications | ✗ | ✓ | ✓ |
get_service_debug_info | ✗ | ✗ | ✓ |
Where ✗ means the call returns StatusCode::UnknownTransaction (or false
for the bool-returning is_declared) because the underlying server protocol
predates the API. Android 10 falls back to the legacy C IServiceManager,
which only learned the AIDL-based interface in Android 11; get_service_debug_info
was added in Android 12. On Linux, rsbinder always uses the Android 16
protocol — what rsb_hub implements — so every row in the Android 12+
column applies. is_declared answers from the declarations above rather
than from a VINTF manifest, so it is false only when nothing declares the
name.
Using the ServiceManager Object Directly
The convenience functions (hub::add_service, hub::check_service, etc.) use
a global singleton ServiceManager under the hood. If you need more control,
you can obtain the ServiceManager instance directly:
#![allow(unused)] fn main() { use rsbinder::hub; // `hub::default()` returns `Result<Arc<ServiceManager>, StatusCode>` — the // error covers things like an unsupported service-manager protocol. // Reaching it before `ProcessState::init_default()` is a programming error // rather than a runtime condition, so that case panics instead, and every // convenience wrapper in `hub` inherits both behaviors. let sm = hub::default()?; // Use methods on the ServiceManager instance let service = sm.check_service("com.example.myservice"); let services = sm.list_services(hub::DUMP_FLAG_PRIORITY_ALL); }
This is equivalent to using the free functions but allows you to pass the service manager as a parameter or store it in a struct.
Tips
-
ProcessState::init_default()comes first. Everyhub::function panics without it, including the ones whose signature cannot report failure. -
Wait, do not poll. A client that starts before its service should call
wait_for_interface/wait_for_service, which is event-driven when a binder thread pool is running, rather than looping oncheck_service. Useregister_for_notificationswhen you need every (re-)registration over time — it, too, needs the thread pool. -
Reach for the shell before writing a program.
rsb_service list/info/check/dump manageranswer most "is it registered, and who owns it" questions directly — see Inspecting a running HUB.
Storing Values (Data Serialization)
An AIDL definition is a schema, and the Rust that rsbinder-aidl generates
from it is already a complete serializer. Until now it had nowhere to write
except a transaction, so a project that wanted to save one of its own
types reached for protobuf or serde and ended up maintaining a second
description of the same data.
Two functions remove that second description:
#![allow(unused)] fn main() { let bytes = rsbinder::to_bytes(&settings)?; std::fs::write("settings.bin", &bytes)?; let settings: Settings = rsbinder::from_bytes(&std::fs::read("settings.bin")?)?; }
Neither needs a feature. The encoder runs in the same session-less parcel
mode the RPC transport uses, and the absence of a session is what refuses
binders and file descriptors — so storage costs nothing to a build that
only speaks kernel binder. The #[derive(rsbinder::Parcelable)] below does
need macros, which is not on by default; a type generated from .aidl
does not, since the generator emits the Parcelable impl directly.
Everything on this page is demonstrated end to end, against real .aidl
definitions, by
example-hello/src/bin/serde_demo.rs
— it needs no binder device, no service manager and no socket:
$ cargo run -p example-hello --bin serde_demo
What you can store
Anything that implements Serialize works, but a type you intend to keep
should be a parcelable. Nothing about the declaration is
storage-specific — it is an ordinary .aidl file, compiled by an ordinary
build.rs:
// aidl/settings/Settings.aidl
package settings;
@RustDerive(Clone=true, PartialEq=true)
parcelable Settings {
String name = "unnamed";
int volume = 50;
String[] tags;
@nullable String note;
}
#![allow(unused)] fn main() { // build.rs — the same call any interface gets. rsbinder_aidl::Builder::new() .source(PathBuf::from("aidl/settings/Settings.aidl")) .output(PathBuf::from("settings.rs")) .generate()?; }
#![allow(unused)] fn main() { use settings::Settings::Settings; let value = Settings { name: "studio".into(), volume: 72, ..Default::default() }; std::fs::write("settings.bin", rsbinder::to_bytes(&value)?)?; let restored: Settings = rsbinder::from_bytes(&std::fs::read("settings.bin")?)?; }
The same type can still be sent through a transaction; storing it is just a second destination for the codec the generator already emitted.
If the type has no non-Rust consumer, the interface macros declare the same thing without a file or a build step — the emitted codec is byte-for-byte identical:
#![allow(unused)] fn main() { #[derive(rsbinder::Parcelable, Default, Debug, Clone, PartialEq)] #[parcelable(descriptor = "settings.Settings")] pub struct Settings { pub name: String, pub volume: i32, pub tags: Vec<String>, pub note: Option<String>, } }
Being a parcelable matters for a reason worth being explicit about. A parcelable writes a length header before its fields, and that header is the entire forward-compatibility story: a reader built against an older definition stops at the boundary the writer wrote, and a reader built against a newer one defaults the fields that were never written. So this works:
| Writer | Reader | Result |
|---|---|---|
| v2 (two extra fields) | v1 | v1's fields intact, the extras skipped |
| v1 | v2 (two extra fields) | v1's fields intact, the extras default |
rsbinder::to_bytes(&42i32) is four bytes with no header and no way to
evolve. Wrap what you store in a parcelable.
The one rule for schema changes is the one every positional format has: append only. Reordering or removing a field changes what the bytes at each position mean, and nothing will tell you — the reader will decode whatever is there.
What you cannot store
Binders and file descriptors. Neither means anything outside the process that produced it: a binder handle is an index into that process's table, and an fd is an index into its descriptor table. Storing either would produce bytes that look valid and refer to nothing.
So a value containing one is refused at the point it is written — before any
dup — rather than encoded:
#![allow(unused)] fn main() { // Err(StatusCode::FdsNotAllowed); the file descriptor is never duplicated. let bytes = rsbinder::to_bytes(&value_with_an_fd)?; }
A binder is BadType rather than FdsNotAllowed — the two conditions are
distinct. FdsNotAllowed is what Android's libbinder returns for a
session that permits no fds; the binder case has no AOSP counterpart, since
there is no session-less parcel there to write one into.
The refusal runs in the other direction too. Bytes that merely look like a binder object are never turned into one: the decoder has no object table to resolve them against and returns an error instead of fabricating a reference. That property is what makes it safe to read a file you did not write.
Reading is strict
from_bytes requires the input to be consumed exactly:
| Input | Result |
|---|---|
| Ends inside the value | Err(NotEnoughData) |
| Decodes with bytes left over | Err(BadValue) |
Leftover bytes usually mean the file was written as a different type, and returning the partially-decoded value would hide that behind a plausible-looking wrong answer.
The bytes are the IPC bytes
There is no separate storage format. to_bytes produces exactly what a peer
would receive for the same value over kernel binder or RPC — same codec,
same layout — and since the wire is fixed little-endian, a value that
carries no object is the same bytes on any architecture. A file written on
an aarch64 phone reads on an x86_64 server.
The exception is a null binder. It is written as a 24-byte object header
whose type word stays host-native, and it is recorded in no object table,
so the refusals above do not catch it. It can only get in via a
ParcelableHolder whose bytes were copied from a kernel parcel — but such
a file is not portable between hosts of different endianness, and the value
it came from does not read back even on the host that wrote it: decoding the
payload returns BadType, because the decoder has no session to marshal a
binder through.
What this buys you: a value can move between a file, a socket and a transaction without being re-encoded, and one schema describes all three.
What this is not
It is not a protobuf replacement. The format is positional and untagged,
strings are UTF-16 (roughly double the bytes of UTF-8), and everything is
padded to a 4-byte boundary. There is no self-describing type information,
so nothing can tell you that a file holds a Settings rather than a
Preferences with a compatible prefix — that is your framing to add if you
need it.
It is for dropping a type you have already described in AIDL onto disk, and it is very good at that.
RPC Transport (binder-over-socket)
rsbinder ships two parallel Binder stacks:
- Kernel binder — the traditional path through
/dev/binder//dev/binderfs/binder, the rsb_hub or Androidservicemanager, and the kernel binder driver. This is what every chapter so far has covered. - RPC transport — binder-over-socket, AOSP's
RpcServer/RpcSessionequivalent in pure Rust. No kernel binder, noProcessState, no service manager. A socket (Unix-domain, vsock, or TLS over TCP) carries the sameParcelpayload between two processes, optionally on different machines.
Both stacks drive the same generated AIDL stubs. A client written
against IHello doesn't know — and doesn't need to know — whether the
proxy underneath talks to a kernel binder handle or an RPC socket.
This chapter introduces when to use RPC, how to stand up a minimal client/server pair, and the runtime/platform/security trade-offs that distinguish RPC from kernel binder.
Feature flag. RPC is gated behind the
rpcCargo feature, off by default. Builds withoutrpccarry zero RPC code and zero extra dependencies. Enable per crate:[dependencies] rsbinder = { version = "0.12", features = ["rpc"] }
When to use RPC vs. kernel binder
| Need | Stack |
|---|---|
| Two processes on the same Linux/Android host | Kernel binder |
| Pure user-space, no kernel binder driver | RPC (Unix) |
| Cross-host / cross-VM binder calls | RPC (vsock/TLS) |
| Runs on macOS as the host platform | RPC only |
Wants Android's getCallingUid() / SELinux for free | Kernel binder |
| Needs custom transport (TLS, mTLS, …) | RPC |
Kernel binder is faster (single ioctl per transaction, shared
memory) and gives you Android's security model for free. RPC trades
some of that for portability and reach — it works on Linux, Android,
and macOS, with no kernel driver and no service manager.
A minimal RPC service and client
The complete example lives under
example-hello.
Once you've enabled the rpc feature on the workspace, run:
# Terminal 1
$ cargo run -p example-hello --features rpc --bin rpc_hello_service
# Terminal 2
$ cargo run -p example-hello --features rpc --bin rpc_hello_client
Server
use rsbinder::rpc::RpcServer; use rsbinder::*; use example_hello::*; const RPC_SOCKET: &str = "/tmp/rsb_hello_rpc.sock"; struct IHelloService; impl Interface for IHelloService {} impl IHello for IHelloService { fn echo(&self, echo: &str) -> rsbinder::BinderResult<String> { Ok(echo.to_owned()) } } fn main() -> std::result::Result<(), Box<dyn std::error::Error>> { // No ProcessState, no hub. RPC never touches the kernel binder. let _ = std::fs::remove_file(RPC_SOCKET); let server = RpcServer::setup_unix_server(RPC_SOCKET)?; // Publish a root binder. Clients fetch this via get_root(). server.set_root(BnHello::new_binder(IHelloService {}).as_binder())?; // Accept loop runs on this thread until stop_accepting(). server.run()?; Ok(()) }
Key points:
- No
ProcessState::init_default()— the RPC stack is independent of the kernel binder singleton. Mixing the two stacks in one process is fine (the Accessor pattern below relies on it), but a pure-RPC process needs neitherProcessStatenorrsb_hub. set_rootpublishes the single root binder. The client fetches it back throughRpcSession::get_root— this is the moral equivalent ofhub::wait_for_interface(name)for kernel binder, just without a service manager.server.run()runs the accept loop until [RpcServer::stop_accepting] is called from another thread. Use [RpcServer::run_background] to spawn it on a dedicated thread instead.stop_acceptingonly raises the flag — sessions already connected drain as their peers leave. [RpcServer::terminate] ends those too (every session closed, every worker joined); it is what dropping aServerGuarddoes. See How a connection ends.
Client
use rsbinder::rpc::RpcSession; use rsbinder::{FromIBinder, Strong}; use example_hello::*; const RPC_SOCKET: &str = "/tmp/rsb_hello_rpc.sock"; fn main() -> std::result::Result<(), Box<dyn std::error::Error>> { let session = RpcSession::setup_unix_client(RPC_SOCKET)?; let root = session.get_root()?; // Same generated stub as the kernel path — try_from picks the // RPC proxy under the hood. let hello: Strong<dyn IHello> = <dyn IHello as FromIBinder>::try_from(root)?; let reply = hello.echo("Hello over RPC!")?; println!("server replied {reply:?}"); Ok(()) }
The generated Bp* proxies dispatch through as_remote(), which
returns either the kernel handle or the RPC proxy depending on what
the SIBinder came from. One AIDL stub, two stacks — there is no
separate "RPC client API" you need to learn.
Multiple named services on one server
RpcServer::set_root publishes a single root binder. If you need to
expose multiple named services on the same socket — the moral
equivalent of the kernel-binder hub::add_service(name, …) flow — use
[RpcServer::add_service] instead. The first call automatically turns
the root into a built-in service directory, so clients reach each
service by name through [RpcSession::get_service]:
#![allow(unused)] fn main() { // Server let server = RpcServer::setup_unix_server(SOCKET)?; server.add_service("hello", BnHello::new_binder(IHelloService).as_binder())?; server.add_service("echo", BnEcho::new_binder(IEchoService).as_binder())?; server.run()?; // Client let session = RpcSession::setup_unix_client(SOCKET)?; let hello: Strong<dyn IHello> = <dyn IHello as FromIBinder>::try_from(session.get_service("hello")?)?; let echo: Strong<dyn IEcho> = <dyn IEcho as FromIBinder>::try_from(session.get_service("echo")?)?; }
add_service is O(1) per call: the directory is built once and shares
the server's service map, so later registrations — even after the server
is already serving — are seen through the same root with no rebuild.
Mixing set_root with add_service on the same server is not
supported (the last one installed wins); pick one publishing model per
server.
For registering and looking up services with the same code over kernel binder or RPC, see Cross-Transport Services.
Wire-protocol profiles
rsbinder speaks two RPC wire profiles, chosen at connect time:
| Profile | Default? | Constructor |
|---|---|---|
| Android 12 (r34) | yes | RpcSession::setup_unix_client(path) |
| Android 13–16 | opt-in | RpcSession::setup_unix_client_android13plus(path, max_version) |
The android-13+ profile is the protocol real AOSP libbinder speaks
today. It runs a handshake on connect and negotiates a wire version:
max_version | AOSP version |
|---|---|
0 | Android 13 |
1 | Android 14 / 15 |
2 | Android 16 |
The server picks the highest version both sides advertise:
#![allow(unused)] fn main() { // Server: offer up to android-16 wire v2. let server = RpcServer::setup_unix_server("/tmp/foo.sock")?; server.set_android13plus(2); server.set_root(my_root)?; // Client: offer up to v2. Negotiation picks min(client, server). let session = RpcSession::setup_unix_client_android13plus( "/tmp/foo.sock", 2, )?; }
The android-13+ profile has been validated end-to-end against real
AOSP libbinder on Android 13/14/15/16 emulators (full parcel-body
transact, byte-correct), so an rsbinder RPC server can serve a real
Android libbinder client and vice versa.
The Android 12 (r34) profile
The default profile speaks the wire of Android 12 and 12L
(android-12.0.0_r1 through r34, SDK 31 and 32), whose RPC code did
not change across those releases. It has no handshake:
- A client opens each connection by writing an
int32session id:-1for a new session. After that every message is a 16-byteRpcWireHeaderand the body it announces, with no length prefix. - Every binder in a parcel, a null one included, is followed by its
stability as Android 12 writes it (
0x0c000001for the defaultStability::System,0x00000001for null), whatever the host's SDK. - An
RpcServermints an id for each new session. An Android 12 client readsGET_MAX_THREADSandGET_SESSION_ID, then opensmax_threads - 1more connections that write that id; the server serves each as its own connection of the session, so calls on different connections run at once. A local connection joins only from the founding connection's uid, a check Android 12 does not make.RpcSession::get_session_idreturns the id's 4 bytes. - An rsbinder r34 client opens one connection and does not join more.
Android 12 libbinder differs from what rsbinder offers on this wire:
| Android 12 libbinder | rsbinder r34 | |
|---|---|---|
| Largest message body | 100,000 bytes (kMaxTransactionAllocation): it refuses to send more (NO_MEMORY). A server connection that receives more closes; a client waiting for a larger reply gets NO_MEMORY with the body left unread, and its next call on that connection ends its session | MAX_FRAME_LEN (64 MiB) |
| TLS | none | r34 inside TLS, between rsbinder peers only |
| Incoming (callback) connections | none | none |
| File descriptors | refused (BAD_TYPE) | negotiate_fd_transport, between rsbinder peers only; against Android 12 it returns None |
| A session ends | when its last connection does | when any one of its connections does |
Keep a parcel exchanged with Android 12 under 100,000 bytes. The last row matters only when one of a session's connections fails on its own: an Android 12 client opens and closes them together.
0.12.0 changed this wire. Releases before it framed every r34 message with a
u32length and wrote no session id or stability, so they cannot talk to Android 12 libbinder, and a 0.12.0 peer and an older one refuse each other's first bytes. Upgrade both ends together. A customRpcTransportmust implementsend_raw/recv_raw; see the CHANGELOG.
This profile has been validated against real Android 12 libbinder on an
SDK 31 emulator, both ways: an Android 12 client against an rsbinder
RpcServer with three connections per session (parallel calls, oneway
order, nested callbacks, null binders, reference counts, the size limit),
and an rsbinder client against an Android 12 RpcServer
(example-hello/cpp/run_rpc_r34_interop.sh).
Transports
RpcSession/RpcServer are transport-agnostic. The bundled
backends are:
| Backend | Feature flag | Trust boundary |
|---|---|---|
| Unix socket | rpc (always on) | Filesystem perms + SO_PEERCRED/getpeereid |
| vsock | rpc-vsock | Hypervisor VM isolation (host ↔ VM) |
| TLS / TCP | rpc-tls | TLS certificate chain (caller-owned rustls) |
| Plain TCP | rpc-tcp-debug | None — debug/interop only, never production |
Add the matching feature in Cargo.toml:
[dependencies]
rsbinder = { version = "0.12", features = ["rpc", "rpc-vsock"] }
Each backend implements the
RpcTransport
trait — you can implement your own if you need a custom carrier. A
session moves its bytes through send_raw / recv_raw on both wire
profiles, so a transport of your own implements those; the trait's
send_frame / recv_frame are a separate length-prefixed API no session
calls.
Abstract Unix-domain sockets (Linux/Android)
Since 0.10.0 the Unix backend can also bind the Linux abstract socket namespace — a kernel-namespace name with no filesystem entry, so there is no socket file to create, unlink, or leak:
#![allow(unused)] fn main() { // Server: no filesystem path, nothing to clean up on shutdown. let server = RpcServer::setup_unix_server_abstract(b"my.rpc.name")?; // Client, r34 wire: let session = RpcSession::setup_unix_client_abstract(b"my.rpc.name")?; // Client, android-13+ wire (negotiating up to v2): let session = RpcSession::setup_unix_client_android13plus_abstract(b"my.rpc.name", 2)?; }
These functions exist only on Linux and Android (the abstract namespace is a Linux kernel feature).
For the android-13+ client there is also a builder,
RpcClientConfig,
consumed by RpcSession::setup_client_android13plus_with_config. It has
one constructor per transport — unix, unix_abstract, vsock, tls,
tcp_debug, and new for a connect function of your own — and the same
knobs on all of them: an outgoing-connection fan-out, incoming (callback)
connections, and the fd transport mode:
#![allow(unused)] fn main() { use rsbinder::rpc::{FileDescriptorTransportMode, RpcClientConfig, RpcSession}; let session = RpcSession::setup_client_android13plus_with_config( RpcClientConfig::unix_abstract(b"my.rpc.name", 2) .outgoing_connections(2) .fd_mode(FileDescriptorTransportMode::Unix), )?; }
The Unix-only predecessor RpcUnixClientConfig, and the
setup_unix_client_android13plus_{with_config,with_id,fan_out} helpers,
are deprecated since 0.12.0 and will be removed in the release after it.
The builder's session_id knob is for the manual attach calls,
add_outgoing_connection_with_config and
add_incoming_connection_with_config, which add one connection to a
session you already hold. Every entry that builds a new RpcSession —
setup_client_android13plus_with_config, the deprecated
setup_unix_client_android13plus_with_id and
connect_android13plus_fd_with_id, and the entry API's deprecated
ClientOptions::session_id — refuses a non-empty id with BadValue. A
second client RpcSession on one server session would keep its own oneway
numbering, binder addresses and lifetime: the server would drop one side's
oneway calls as out of order, could dispatch a call meant for one side's
binder to the other side's binder at the same address, and would end the
shared session when either side closed. AOSP has no public entry for this
either: RpcSession::setupClient is private, and the connections that
echo the id belong to the session that read it. For the same reason a
manual attach accepts only the id of the session it is called on: any
other id, such as one read from another RpcSession, is BadValue
before connecting.
The id you echo must come from
RpcSession::get_session_id()on the session you attach to — one round trip that asks the server for it, kept by the session.RpcSession::session_id()is a different method: on a client session it returns a client-local value that the server has never seen. Both return 32 opaque bytes, so nothing but the method name distinguishes them; the attach calls refuse every id but the session's own withBadValuebefore connecting. An attach the server refuses (for example, more connections than itsset_max_threadsallows) fails at the attach call, and onadd_outgoing_connection_with_configthat failure also ends the session it was attaching to (see How a connection ends); stay withinnegotiate()connections.
Security. An abstract socket has no filesystem permissions: any process in the same network namespace can connect (subject only to LSM policy such as SELinux), so the directory-mode access control a path-bound socket can rely on does not apply. When exposure matters, install an [
RpcServer::set_authorizer] hook (see Security) and check the peer identity (uid/gid/pid) it receives.
TLS client
#![allow(unused)] fn main() { use rsbinder::rpc::rustls; use rsbinder::{Client, Endpoint}; use std::{sync::Arc, time::Duration}; let mut roots = rustls::RootCertStore::empty(); // ... populate `roots` from your trust anchors ... let config = Arc::new( rustls::ClientConfig::builder() .with_root_certificates(roots) .with_no_client_auth(), ); // The certificate is verified against the host name unless // `tls_server_name` names another. let client = Client::open_with(Endpoint::tls("binder.example.com", 9999), |o, _| { o.tls = Some(config); o.timeout = Some(Duration::from_secs(10)); })?; let root = client.session().expect("an RPC client").get_root()?; }
timeout bounds each step of connecting — connect(2) across every
address the host name resolves to, the TLS handshake, and on
?profile=android13plus the android-13+ handshake — and then the
session's reply waits (Timeouts). Without it, a server that
accepts the connection and then sends nothing hangs open_with.
RpcSession::setup_tcp_client_tls and setup_tcp_client_tls_android13plus
take no deadline at all; RpcClientConfig::tls(..).timeout(d) is the
bounded form of the second.
rsbinder::rpc::rustls is the exact rustls version the rpc-tls
backend links against, re-exported so you don't have to track it in
your own Cargo.toml.
TLS server
The server side takes a rustls::ServerConfig carrying its certificate
chain and private key. RpcServer::setup_tcp_server_tls runs the TLS
handshake on each connection's own worker thread (so a slow-handshake
peer stalls only its worker, never the accept loop), then serves the
same session as any other backend:
#![allow(unused)] fn main() { use rsbinder::rpc::{rustls, RpcServer}; use rustls::pki_types::pem::PemObject; use rustls::pki_types::{CertificateDer, PrivateKeyDer}; use std::sync::Arc; // Server certificate chain + private key, loaded from PEM. let certs: Vec<_> = CertificateDer::pem_slice_iter(SERVER_CERT_PEM.as_bytes()).collect::<Result<_, _>>()?; let key = PrivateKeyDer::from_pem_slice(SERVER_KEY_PEM.as_bytes())?; let config = Arc::new( rustls::ServerConfig::builder() .with_no_client_auth() // one-way TLS — see "Who needs a key?" .with_single_cert(certs, key)?, ); // TCP is TLS-only by design: there is no plaintext-TCP server constructor. let server = RpcServer::setup_tcp_server_tls("0.0.0.0:9999", config)?; server.set_root(my_root_binder)?; server.run()?; // or server.run_background() }
RpcServer::setup_unix_server_tls and (with rpc-vsock)
RpcServer::setup_vsock_server_tls do the same over a Unix socket /
vsock — TLS is orthogonal to the socket kind (it mirrors AOSP's
RpcTransportCtx::newTransport(fd)).
Who needs a key?
| Mode | Server | Client |
|---|---|---|
| One-way TLS (default) | cert chain + private key (always) | trusted roots only — no key |
| mTLS (mutual) | cert chain + private key and a client-cert verifier | cert chain + private key |
-
One-way TLS is the common case. The server proves its identity with its certificate; the client only verifies that certificate against its
RootCertStore(with_no_client_auth()on both configs above). The client needs no key of its own — exactly like a browser connecting to an HTTPS site. -
mTLS additionally authenticates the client. Build the server config with a client-cert verifier (
rustls::server::WebPkiClientVerifier::builder(Arc::new(roots)).build()?→.with_client_cert_verifier(verifier)) instead of.with_no_client_auth(), and give the client a cert + key (.with_client_auth_cert(client_certs, client_key)?instead of the client's.with_no_client_auth()). The handshake then fails unless the client presents a certificate the verifier trusts, and the server sees that client's leaf cert as itsPeerIdentity::Certificate.
Either way the certificate check happens during the TLS handshake:
an untrusted, missing, or expired certificate fails the connection
before a single RPC byte is exchanged. rsbinder never invents crypto —
all key / cert / root management and verification are your rustls
config and rustls itself.
Security
RPC is not a drop-in for kernel binder's security model. Kernel binder gives you a kernel-vouched uid/pid and SELinux for free; RPC's trust boundary is the transport itself. This section covers the RPC trust boundaries; for the full handler-side authorization story across both transports —
calling_caller(),@EnforcePermissionover RPC, uid ACLs,PermissionAuthority— see Security & Authorization.
Each transport defines its own trust boundary, surfaced as a
PeerIdentity:
Local { uid, pid }— Unix socket peer with kernel-vouched credentials.Vsock { cid }— VM context id (a routing address, not an ACL basis on its own — the trust comes from hypervisor isolation).Certificate(CertId)— TLS peer authenticated by its leaf cert.Anonymous— no identity at all. ACL is impossible against an anonymous peer. The debug TCP backend always returns this, and any backend falls back to it (logged loudly) if peer-credential resolution fails — so an authorizer must treatAnonymousas deny.
Use [RpcServer::set_authorizer] to enforce a per-connection policy.
The closure runs once on accept, before any RPC byte is exchanged:
#![allow(unused)] fn main() { const ALLOWED_UID: u32 = 1000; server.set_authorizer(|peer| { peer.uid() == Some(ALLOWED_UID) }); }
A rejected peer's socket is closed; its next operation sees
DeadObject. The hook is opt-in — without it, every connection is
accepted (matching the prior behaviour).
set_authorizer is connection-level (decided once, at handshake) —
the right granularity for vsock and TLS, which carry no per-call uid. For
per-method authorization, a handler can also read the caller directly:
over a Unix-domain RPC connection rsbinder::get_calling_uid() returns the
kernel-vouched peer uid (and calling_caller() the full transport-tagged
identity), exactly as on kernel binder — so a uid ACL written once runs on
both. Over a uid-less transport get_calling_uid() is the fail-closed
u32::MAX sentinel. See Security & Authorization.
Capabilities
The RPC stack is feature-complete for the everyday cases you'd use kernel binder for, with a few extras specific to socket transport:
- AIDL interfaces — every type the AIDL compiler supports works over RPC: primitives, strings, arrays, parcelables, nested interfaces, enums, unions, oneway methods.
- Callbacks (nested binders) — a callback object created on the
client crosses the socket like any other Binder and the server
invokes it back through the same session. A twoway call from inside
a handler always works; a
onewaycall, or a call from any other server thread, needs the client to open an incoming connection — see Callbacks outside a handler. ParcelFileDescriptor— opt in on both ends: the server withRpcServer::set_supported_fd_modes, the client withRpcClientConfig::fd_mode(thefd_modeconnect option of the entry API). On the android-14+ wire the mode rides the connection header and is fixed for the session's lifetime, as in AOSP. A server that has not opted in refuses a client that requestsUnix, as AOSP does: it closes the connection after the handshake, so the client's first call fails withDeadObject. On that wireRpcSession::negotiate_fd_transportsends nothing, and asking it for aUnixmode the header did not agree isStatusCode::InvalidOperation. An android-13 (v0) header carries no fd mode, so such a session passes no fds.negotiate_fd_transportnegotiates only on the r34 wire, which has no fd mode in AOSP, as an rsbinder-only exchange after connect; against AOSP r34 libbinder it returnsNone. File descriptors ride out-of-band overSCM_RIGHTSon Unix-domain sockets.- Death notifications — link a
DeathRecipienton the proxy as usual. A session that ends — its peer closes, any one of its connections fails, or a deadline expires (see Timeouts) — fires every linked recipient (the RPC analogue of "the remote process died"). - Async — the same
into_async::<Tokio>()adapter that wraps a blocking kernel-binder proxy works over RPC. See Async Service — for RPC the only difference is how you obtain the proxy.
Sessions with multiple connections
RpcServer::set_max_threads(N) matches AOSP's
setMaxIncomingThreads. Both N == 1 (the default — one connection
per session, the mode every example in the book uses) and N >= 2
(multi-connection sessions) are validated against real Android 13–16
libbinder peers. On the r34 wire N is how many connections an
Android 12 client opens (The Android 12 (r34) profile). See the rustdoc on
RpcServer::set_max_threads
for the per-mode details.
set_max_threads caps incoming slots per session. To cap
server-wide concurrent connections — for example to bound the
worker-thread fan-out regardless of how many sessions a single client
opens — use
RpcServer::set_max_connections(N)
(default: unlimited). Both knobs are independent and additive.
Callbacks outside a handler
A server can always make a twoway call to a client's callback while
it is answering that client — the nested call rides the connection
the request came in on. Calling it from anywhere else (a timer, a
worker thread, a oneway notification fired later) needs a connection
the server can send on, and by default a session has none: the
server fails such a call at once with WouldBlock (AOSP WOULD_BLOCK)
rather than waiting for a slot that will never free up.
A oneway call needs that connection even from inside the handler.
It never rides the connection the request came in on, as in AOSP
libbinder ("asynchronous calls cannot be nested"): the server does not
wait for it and goes on to write its reply, while the client may still
be running the oneway inside its own reply wait — and a twoway call the
client's handler makes back on that connection would then read the
server's reply as its own.
The client provides that connection, exactly as libbinder's
ARpcSession_setMaxIncomingThreads(n) does:
#![allow(unused)] fn main() { use rsbinder::rpc::{RpcClientConfig, RpcSession}; let session = RpcSession::setup_client_android13plus_with_config( RpcClientConfig::unix(std::path::Path::new(RPC_SOCKET), 2).incoming_connections(1), )?; }
or, through the unified entry, Client::open_with(uri, |o, _| { o.incoming_connections = Some(1) }) on a Uri with
WireProfile::Android13Plus (?profile=android13plus). This works on every RPC transport — unix://, vsock:// and
tls:// alike. Each incoming connection is a further connection to the
same endpoint (over tls://, its own TLS session), attached to the same
session and served by a thread the session owns; n of them let n
server threads call back in parallel. For a transport the entry does not
name — TLS over a Unix socket, say — build the connections yourself with
RpcClientConfig::new(version, connect), whose connect is called once
per connection. The server budgets them at
2 × set_max_threads per session (two on a default server) and
refuses the rest, which the client sees as a setup error.
Two consequences worth knowing:
- Death can be observed at all. The serving thread sees the server's
connection drop and fires the linked death recipients immediately. A
client session without incoming connections has nobody reading its
connection, so
link_to_deathis refused there withInvalidOperation(AOSP refuses it the same way). The other way to get notified isRpcSession::spawn_serve, which dedicates the founding connection to a serve loop: the session then makes no calls of its own, only nested ones from inside a handler. - Stop the session explicitly. The serving threads keep the session
alive; dropping the
RpcSessionhandle and every proxy does not end them. CallRpcSession::close_session()when you are done — it shuts the connections down and joins the threads.
This needs the android-13+ profile (the attach echoes the session id).
Streaming is the first thing in rsbinder itself that
depends on it: a producer pushes batches from a thread of its own, so
Sink::open checks for TransportCaps::CALLBACKS and refuses a session
that has no incoming connection.
How a connection ends
A session ends as a whole, never one connection at a time. As in AOSP
libbinder (RpcState::handleRpcError), a send or receive that fails on
any of its connections shuts the session down: every connection
closes, calls in flight on the others fail with DeadObject — whether
the peer ran them is unknown — every linked death recipient fires, and
every local object the peer held is released. A single connection
cannot be resumed: the wire has no frame numbering or acknowledgement
that would tell which oneway call or reference-count frame the lost
connection took with it, and a session that carried on without them
would leave an object's later oneway calls queued behind a number
that never arrives. Reconnecting, fetching the root again and
registering callbacks again are the application's;
Reconnecting does all three. A connection whose
handshake fails never joins the session. A server's callback
connection whose connection-init write fails is dropped alone, since
no frame rode it and the client's attach fails with it. A client's
attach, in either direction, ends the session once the server may hold
the connection: any failure after its header went out, except, for a
callback attach, a close (a reset included) before the first byte of
the server's "cci"; see add_incoming_connection_with_config. An
outgoing attach has no such exception, because libbinder
android-16.0.0_r3 and later end their session when they refuse one
at the setMaxThreads cap, and the close that refusal produces looks
the same as any other; see add_outgoing_connection_with_config. A client's callback connection
whose serving thread cannot be started ends the session too, since the
server already holds it as a callback connection.
A serve loop — RpcSession::serve_blocking and its variants — returns
rpc::SessionEnd, not Result<()>. It answers three questions a
status code cannot hold at once:
stream— is the stream still at a frame boundary (StreamState::InSync), or is its position lost (Lost: a cut frame, a TLS stream that ended withoutclose_notify, a deadline that expired part-way through a frame)?by— did this end decide (EndedBy::Local:close_session,terminate, an idle deadline this end armed), or not (NotLocal)?reason— what happened (EndReason), for logs.
SessionEnd::into_result() is the one projection back to a Result:
Ok(()) for an intact stream, Err(DeadObject) for a lost one. Only
stream decides it; by is attribution.
Each call on the ending side means one thing:
| call | does |
|---|---|
RpcSession::close_session() | declare the session dead: every connection's transport is shut down, cached proxies get binder_died, the session's incoming threads are joined |
RpcServer::stop_accepting() | raise the accept flag; connected sessions drain as their peers leave; nothing is joined |
RpcServer::terminate() | stop accepting, close every session the server minted, join the workers |
ServerGuard::stop_and_join() | terminate, after joining the accept thread — the same as dropping the guard |
RpcTransport::shutdown() | the transport half-close itself (shutdown(2) in both directions): wakes a reader blocked on this end, fails this end's later sends |
What this end's own reader sees after its shutdown is
platform-dependent — Linux delivers frames already queued ahead of the
end of stream, macOS discards them — so code must not assume a queued
frame arrives, or that it does not. The peer reads the end of stream on
either platform; what differs is the peer's next send (Linux
AF_UNIX: EPIPE at once; macOS: accepted and discarded; TCP: a reset
on a later write).
Timeouts
AOSP libbinder's RPC path has no timeouts: every socket wait is a
poll without a bound, and only a session shutdown ends it. Every
deadline below is an rsbinder addition, and each measures one thing.
| Setting | What it bounds | On expiry |
|---|---|---|
RpcSession::set_timeout(d); RpcClientConfig::timeout, ClientOptions::timeout; on a server RpcServer::set_reply_timeout, ServeOptions::reply_timeout, applied to every session it makes | How long the peer may leave this end without an answer: a reply wait; a send that makes no progress (SO_SNDTIMEO, every bundled socket transport; a transport of your own only if it implements RpcTransport::set_write_timeout; on Linux and Android progress is the peer taking this end's bytes, SIOCOUTQ, which a reply wait counts too, and Apple counts the bytes a send accepted: transport module doc "A slow peer"); the peer's host not answering (the kernel's keepalive check, TCP and TLS over TCP); on a client, each connect and handshake step (connect(2) itself only for tcp_debug and tls, where it is one step across every address the host resolves to: a unix connect waits while the listener's accept queue is full, and the kernel bounds a vsock one by its own connect timeout) | The session ends; the call that waited returns TimedOut. A connect or handshake step: the setup or attach call returns TimedOut, and an attach whose step expires after its header went out ends the session (How a connection ends) |
The same d, for a free connection in the session's pool | A wait for a connection to send on; nothing has been sent yet | That call returns WouldBlock; the session goes on |
RpcServer::set_handshake_timeout, ServeOptions::handshake_timeout (default 10 s) | An accepted connection from its accept to admission, its TLS handshake and first contact included, as a whole: a peer that sends its handshake a byte at a time does not stretch it. The set_authorizer hook's run is not counted. A transport whose RpcTransport::shutdown_handle is None, the default for one of your own, is bounded per wait only, by the set_read_timeout and set_write_timeout it implements (neither: no bound) | The connection is dropped |
RpcServer::set_idle_timeout, ServeOptions::idle_timeout | A session in which no byte crossed any connection and no call was open, judged per session (set_idle_timeout states the timing and what counts) | The session ends |
Set the session timeout above the slowest legitimate handler. It is how long the peer may go without answering before it counts as broken, not a budget for one call: a call that times out takes the session with it, because the reply that is late may still come and nothing could tell it apart from the next one. A caller that wants to give up on one call and keep the session runs the call on another thread, or as a future, and stops waiting for it. There is no default: a library cannot know how long a handler may legitimately run, and neither kernel binder nor libbinder bounds it.
Keepalive is on for every TCP connection, TLS over TCP included.
Without a session timeout it runs at the system's intervals, so a session whose peer host
vanished ends, and fires its death recipients, after hours instead of
never; set_timeout(d) sizes the check to d. The values, and what
Linux, Android and macOS each do with them, are stated once, in
RpcTransport::set_liveness.
The probes are between the two kernels and change no byte on the wire,
so a libbinder peer needs nothing. Unix sockets and vsock, TLS over them
included, have no such check.
A relay hides a break. Keepalive reaches only the first TCP
endpoint on the path. Behind a relay that terminates TCP — adb forward, ssh -L, a TLS terminator — the relay answers the probes,
and a break past it shows only when a call waits for a reply and the
session timeout expires. A stream that waits has no call outstanding,
so it pings its peer instead (Streaming).
On a server the reply timeout is also the liveness bound. A server
that never calls its clients back has no reply to wait for, so the
reply timeout reaches its clients only as keepalive and the send bound.
A server that wants a vanished client's session cleaned up sooner than
the system's keepalive sets set_reply_timeout for that.
The idle timeout is for untrusted peers. A peer that finishes the
handshake and then stays silent holds a worker thread and, under
set_max_connections, an admission slot; keepalive does not catch it
(its kernel answers) and neither does the reply timeout (it asks for
nothing). The idle timeout is for a server that admits unauthenticated
TCP or TLS peers and caps them; one that picks its peers with
set_authorizer or TLS client authentication needs it less. What
counts as idle, including a client that only waits for callbacks, is
stated once, in set_idle_timeout.
RpcClientConfig::handshake_timeout and
ClientOptions::handshake_timeout are deprecated: the client timeout
bounds connecting. A value still set there takes precedence for the
handshake.
Bridging RPC and the service manager: the Accessor pattern
Android 16 introduced IAccessor — a kernel-binder interface whose
sole job is to hand a client a connected RPC socket fd. The client
asks the system service manager for the accessor (a kernel-binder
service), calls addConnection(), and the returned
ParcelFileDescriptor is the RPC socket the client then drives
through the android-13+ handshake.
rsbinder implements both sides of this pattern:
-
Consume side — the shared
hublookup path (hub::check_service,hub::wait_for_service, and the typed/try_*variants) transparently follows theIAccessorarm: if the service manager hands back anIAccessorinstead of a regular binder, rsbinder callsaddConnection(), adopts the fd, runs the v2 handshake, and gives you back the RPC root. Your client code looks identical to a regularhub::check_servicecall. -
Register side —
hub::android_16::create_accessor(instance, addr_provider)builds aLocalAccessorBnAccessoryou can publish viahub::add_service. The provider closure resolves an instance name to anAccessorSockAddr(Unix(path),UnixAbstract(name)— added in 0.10.0 —Vsock { cid, port }, orInet(addr)), and the accessor opens the connection on demand. The enum is intentionally exhaustive (not#[non_exhaustive]), so downstream exhaustivematches need an arm for the newUnixAbstractvariant — see the 0.10.0 CHANGELOG:#![allow(unused)] fn main() { use rsbinder::hub::{self, android_16::{create_accessor, AccessorSockAddr}}; use rsbinder::rpc::RpcServer; use rsbinder::ProcessState; use std::path::PathBuf; // 0. The kernel-binder side needs ProcessState to publish the // accessor through the system service manager. The RPC server // itself does NOT (it runs entirely in user space). ProcessState::init_default()?; ProcessState::start_thread_pool(); // 1. Run a regular RPC server on a UDS. let sock = PathBuf::from("/data/local/tmp/my.sock"); let server = RpcServer::setup_unix_server(&sock)?; server.set_android13plus(2); server.set_root(my_root)?; let _bg = server.run_background(); // 2. Vend an IAccessor that hands clients an fd connected to // `sock`, and publish it through the kernel service manager. let path = sock.clone(); let accessor = create_accessor("my.service", Box::new(move |_name| { Ok(AccessorSockAddr::Unix(path.clone())) })); hub::add_service("my.service", accessor)?; // 3. Block on the kernel binder thread pool so the accessor stays // reachable for the lifetime of the process. ProcessState::join_thread_pool()?; }
Use
hub::android_16::add_accessor_provider
when you want a process-local accessor — one that doesn't go
through the system service manager. Lookups via hub::check_service /
hub::wait_for_service in the same process fall back to the
process-local registry when the service manager returns nothing.
Both sides of the Accessor pattern have been validated against real
Android 16 libbinder on the emulator.
The Accessor solves one direction — a kernel client reaching an RPC service. For the reverse, and for the rule that binder objects never cross between the two stacks, see Cross-Transport Services § Bridging two transports in one process.
Async over RPC
The blocking I/O the RPC stack uses is deliberate — it matches
the AOSP RpcServer/RpcSession model, where each connection has its
own pair of blocking threads. The standard
spawn_blocking/block_on adapters that already wrap kernel-binder
async work for RPC too:
#![allow(unused)] fn main() { use rsbinder::Tokio; let session = RpcSession::setup_unix_client(RPC_SOCKET)?; let root = session.get_root()?; let hello: Strong<dyn IHello> = <dyn IHello as FromIBinder>::try_from(root)?; // Convert the blocking proxy into the async one. let async_hello = hello.into_async::<Tokio>(); let reply = async_hello.echo("hi").await?; }
For services, Bn*::new_async_binder(impl, TokioRuntime(handle))
works exactly like it does on the kernel path. See
Async Service.
There is intentionally no non-blocking RpcTransport /
reactor-based async serve loop: async support is these adapters over
the blocking stack, not a second I/O engine underneath it.
Platform support
| Platform | Kernel binder | RPC |
|---|---|---|
| Linux | Yes (with binderfs) | Yes (Unix, vsock, TLS) |
| Android | Yes (built in) | Yes |
| macOS | No | Yes (Unix, TLS — for development & cross-stack interop testing) |
| Windows | No | No (untested) |
macOS support for RPC means you can develop, run, and test RPC-only rsbinder applications on a macOS host without needing a Linux VM. The kernel binder code paths remain Linux/Android only.
Further reading
- Async Service — applying the
into_async::<Tokio>()andnew_async_binderpatterns over RPC. - Callbacks and Interfaces — nested binders and death recipients work the same over RPC as kernel binder.
- ParcelFileDescriptor — FD passing
with the android-14+ wire and
SCM_RIGHTS. rsbinder::rpcmodule on docs.rs/rsbinder for the full API surface (every public function, struct, and trait introduced above is documented in detail there).
Cross-Transport Services
The AIDL interface, the generated Bp*/Bn* stubs, your service impl,
and the call sites are already transport-agnostic — the same impl IFoo
runs unchanged over kernel binder or RPC. What differs is only the
bootstrap: kernel binder uses ProcessState + the hub
service manager, while RPC uses RpcServer / RpcSession.
rsbinder::serve and rsbinder::connect make that bootstrap
transport-agnostic too: the destination is a Uri value, and
everything else is the same three calls. The low-level APIs remain the
direct path and are unchanged; this is a thin layer over them with no wire
change.
Three lines per side
#![allow(unused)] fn main() { use rsbinder::*; // Server — kernel binder: ProcessState init + thread pool + service // manager registration + join. The same code with Endpoint::unix("/tmp/x.sock") // binds a socket, publishes the service, and runs the accept loop. rsbinder::serve(Uri::kernel())? .add("hello", BnHello::new_binder(MyService))? .run()?; // Client — open, look up, cast. The proxy alone keeps an RPC session // alive; there is nothing else to hold. let hello: Strong<dyn IHello> = rsbinder::connect(Uri::kernel().with_service("hello"))?; // rsbinder::connect(Endpoint::unix("/tmp/x.sock").with_service("hello"))? hello.echo("hi")?; }
Destinations: Uri
A Uri is an Endpoint (where), an optional service name (what to look
up), and the RPC wire to speak. serve, connect, Client::open and
Reconnecting::builder take anything that converts into one: a Uri, a
&Uri, an Endpoint, or a KernelEndpoint.
| Endpoint | Constructor | Notes |
|---|---|---|
| kernel binder | Uri::kernel(), Endpoint::kernel(), KernelEndpoint::default().with_driver(..).with_threads(..).with_mmap_size(..) | settings apply to the process-wide ProcessState |
| Unix socket | Endpoint::unix(path) | absolute path; any bytes the OS accepts |
| abstract Unix socket | Endpoint::unix_abstract(name) | Linux/Android |
| vsock | Endpoint::vsock(cid, port) | feature rpc-vsock |
| TLS over TCP | Endpoint::tls(host, port) | feature rpc-tls (TCP is TLS-only) |
endpoint.with_service("hello")(oruri.with_service(..)) names the service.serve()ignores it, so a server and its clients can share one value;connect()requires it;Client::open()rejects it.uri.with_wire(WireProfile::Android13Plus(WireVersion::MAX))selects the AOSP versioned wire on an RPC endpoint; the defaultWireProfile::R34speaks the r34 wire. A kernel endpoint takes onlyR34.KernelEndpoint::with_mmap_sizesizes the mapping this process receives transactions into — the real meaning of the "1 MB binder limit". See Receive mapping size.KernelEndpoint,WireProfile,WireVersion,UriErrorandUriErrorKindlive inrsbinder::entry.
String form
Configuration files and command lines carry the same value as a string.
Parse it explicitly — the entry functions take no &str:
#![allow(unused)] fn main() { let uri: Uri = config.target.parse()?; // or Uri::parse(&config.target)? let hello: Strong<dyn IHello> = rsbinder::connect(uri)?; }
binder://[<service>][?driver=<path>&threads=<n>&mmap=<bytes>]
unix://<abs-path>[#<service>] (three slashes: unix:///tmp/x.sock)
unix-abstract://<name>[#<service>] Linux/Android
vsock://<cid>:<port>[#<service>] feature rpc-vsock
tls://<host>:<port>[#<service>] feature rpc-tls; IPv6 host in [ ]
- The service name is the
#fragment;binder://nameis a shorthand forbinder://#name. ?profile=android13plus[-vN]on any RPC scheme isWireProfile::Android13Plus(WireVersion::MAXwhen-vNis omitted).- Paths, names, hosts and the service are percent-decoded, so a path with
#,?,%or bytes that are not UTF-8 is written escaped. - An unknown scheme or query key, a key given twice, an empty
#, orbinder://a#bis aUriError;uri_error.kind()says which.UriErrorconverts intoStatusCode::BadValuefor?(logging the reason) and implementsstd::error::ErrorforBox<dyn Error>. uri.to_string()writes the string form back. Parsing it gives the sameUri, for every valueserve/connectaccept — so build URIs with the constructors, not withformat!.
Server
serve(uri) returns a Server:
add(name, svc) | publish svc (anything Into<SIBinder>, e.g. BnFoo::new_binder(..)). Queued; run / spawn register it after checking the options (kernel: with the service manager, once the thread pool is up; RPC: in the server's directory). |
with(|o| ..) | set ServeOptions |
run() | serve and block. Kernel: start the thread pool and join it (never returns normally). RPC: the accept loop, until shut down. |
spawn() | serve in the background; returns a ServerGuard. RPC: dropping the guard (or ServerGuard::stop_and_join()) ends the server — every session is closed and the threads are joined. Kernel: the guard is inert (the process thread pool has no shutdown). |
Kernel serve(Uri::kernel()) initializes the process-wide ProcessState
idempotently; a second kernel server in the same process reuses it, and is
refused with StatusCode::BadValue if it asked for a different driver,
thread count, or mapping size — the process cannot give it one. The refusal
lands where the option is read: serve() itself for the KernelEndpoint
settings (?driver= / ?threads= / ?mmap=), run/spawn for ServeOptions::threads and
ServeOptions::mmap_size, and open for ClientOptions::driver and
ClientOptions::mmap_size. RPC
servers bind their listener at run/spawn, so options (TLS config, limits)
set via with apply first.
Client
connect(uri) is Client::open(endpoint)?.get(name). Keep a Client only to
issue more lookups on the same endpoint:
#![allow(unused)] fn main() { let c = rsbinder::Client::open(Endpoint::unix("/run/app.sock"))?; let hello: Strong<dyn IHello> = c.get("hello")?; let stats: Strong<dyn IStats> = c.get("stats")?; drop(c); // proxies stay valid }
get::<T>(name) | kernel: waits for registration (AOSP waitForService); RPC: one directory lookup, NameNotFound if absent |
try_get::<T>(name) | non-waiting; Ok(None) when absent |
binder(name) | untyped SIBinder |
session() | the underlying RpcSession (None on kernel binder) |
endpoint() | the Endpoint (mirrors Server::endpoint()) |
Client::open(Uri::kernel()) also starts the binder thread pool, so
callbacks, death notifications, and registration waits are delivered
promptly.
Options
Anything that is not part of the Uri goes through ServeOptions /
ClientOptions:
#![allow(unused)] fn main() { rsbinder::serve(Endpoint::tls("0.0.0.0", 9000))? .with(|o| { o.tls = Some(server_tls_config); // rustls::ServerConfig (feature rpc-tls) o.max_connections = Some(64); o.authorizer = Some(Box::new(|id| id.uid() == Some(1000))); }) .add("hello", BnHello::new_binder(MyService))? .run()?; let hello: Strong<dyn IHello> = rsbinder::Client::open_with(Endpoint::tls("host", 9000), |o, _endpoint| { o.tls = Some(client_tls_config); })? .get("hello")?; }
ServeOptions: threads, mmap_size, max_connections,
handshake_timeout, idle_timeout, reply_timeout, authorizer, tls,
fd_modes, call_restriction.
ClientOptions: tls / tls_server_name, session_id (deprecated: a
non-empty id is refused),
outgoing_connections, incoming_connections, fd_mode, timeout,
handshake_timeout (deprecated: timeout bounds connecting), driver,
mmap_size. What each timeout bounds, and what its expiry ends, is in
Timeouts.
Receive mapping size
A binder transaction is copied into a buffer the driver allocates out of
the receiving process's mapping, so that mapping — not the protocol —
is what bounds a call. rsbinder maps the same ~1 MB AOSP libbinder does;
raise it on a service that must accept larger calls:
#![allow(unused)] fn main() { use rsbinder::entry::KernelEndpoint; rsbinder::serve(KernelEndpoint::default().with_mmap_size(4 << 20))? // 4 MB, the driver's ceiling .add("bulk", BnBulk::new_binder(MyService))? .run()?; }
- Bytes only (no
4Mshorthand), between one page andMAX_BINDER_MMAP_SIZE(4 MB, where the driver clamps silently — a larger request is refused here rather than quietly shrunk), rounded up to a page. A one-page mapping is legal and carries a call of a few KB. - Set on the receiver. A caller needs nothing; a payload too large for
the destination comes back as
StatusCode::FailedTransaction. A client is a receiver of its own replies, which is whatClientOptions::mmap_sizeis for. - Oneway transactions may use only half of it — the driver reserves the rest so an async flood cannot starve synchronous calls.
- Only address space is reserved; pages are faulted in as they are used.
- Process-wide and fixed at the first
serve/Client::open, like the driver and thread count.ProcessState::init_with_mmap_sizeis the direct form, andProcessState::mmap_size()reports what is in force. ServeOptions::mmap_sizecannot make the mapping:serveinitializesProcessStatebefore the option is read, so the field can only agree with the size already in force and a different one isBadValueatrun/spawn— the same rule asServeOptions::threads. What sets the size on a kernel server isKernelEndpoint::with_mmap_size(binder://?mmap=), orProcessState::init_with_mmap_sizecalled beforeserve.
Client::open_with's closure also receives the Endpoint, so an option
that applies to only some transports can be set from it — useful when the
Uri was parsed from configuration:
#![allow(unused)] fn main() { let client = rsbinder::Client::open_with(&uri, |o, endpoint| { if endpoint.supports_fd_passing() { o.fd_mode = Some(FileDescriptorTransportMode::Unix); } })?; }
An option that does not apply to the transport (a TLS config on kernel
binder, call_restriction on a Unix socket) is BadValue at
run/spawn/open time, with a log line naming the option — never
silently ignored. A kernel setting that does apply but the process cannot
honor — a thread count or driver against a ProcessState already
initialized with another value — is refused the same way, at serve() for
the KernelEndpoint form. So is a Uri whose string form could not carry
it: a relative Unix path, an empty abstract name, TLS host or service name,
or a kernel endpoint with WireProfile::Android13Plus. Users who want that
checked at compile time use the low-level types directly.
Bridging two transports in one process
Everything above moves one service onto whichever transport you pick. A different problem is reaching a service that lives on the other transport — a socket client that needs a kernel service, or the reverse. rsbinder has three answers, and which one applies depends on the direction:
| Direction | Mechanism | Where |
|---|---|---|
| kernel client → socket service | Accessor (IAccessor, Android 16 / rsb_hub) — the service manager hands the client a connection to the socket | RPC Transport § Bridging |
| one service, both transports | dual-hosting — the same Bn* passed to two serve(...).add(...) calls | this chapter |
| socket client → kernel service | gateway — a process that re-publishes an upstream proxy as a local service | below |
None of the three moves raw bytes between the stacks. A binder that belongs
to one stack cannot be written into a parcel of the other: rsbinder
refuses it at write time with InvalidOperation, exactly as libbinder does
(Parcel::flattenBinder, RpcState::onBinderLeaving). There is no
transparent bridge, and building one is a non-goal — a generic byte
forwarder would silently change the caller identity, fd rights, stability
and oneway ordering that the two stacks define differently.
The gateway
A gateway process B connects to the upstream service C and publishes it
again on its own endpoint. Because Bn*::new_binder accepts anything
implementing the interface, and a typed handle implements the interface it
points at, that is one line:
#![allow(unused)] fn main() { // B: reach C over kernel binder, re-publish it on a Unix socket. let upstream: Strong<dyn IHello> = rsbinder::connect(Uri::kernel().with_service("my.hello"))?; rsbinder::serve(Endpoint::unix("/tmp/rsb_gw.sock"))? .add("hello", BnHello::new_binder(upstream))? // <- the gateway .run()?; }
Passing the proxy instead — .add("hello", upstream.as_binder()) — is the
mistake this refuses (InvalidOperation at add). Wrapping is not
ceremony: the Bn* is a local binder that B owns, and that is what
makes it publishable.
The rule: every binder object crossing B is unwrapped by B and wrapped again. Plain data flows through untouched. So a method with a binder argument — a callback registration, say — needs B to override just that method and re-wrap the argument before forwarding it:
#![allow(unused)] fn main() { fn register(&self, cb: &Strong<dyn ICallback>) -> BinderResult<()> { self.upstream.register(&BnCallback::new_binder(cb.clone())) } }
Forwarding cb as-is fails with InvalidOperation, reported back to the
original caller.
That inline new_binder is correct for one call and wrong across calls:
each one mints a new object, so register(cb) and unregister(cb) reach C
as two unrelated binders and the removal matches nothing.
bridge::Rewrap
is the table that fixes it — same remote in, same local out:
#![allow(unused)] fn main() { struct Gateway { upstream: Strong<dyn IFoo>, callbacks: Rewrap<dyn ICallback>, // Rewrap::new(BnCallback::new_binder) } fn register(&self, cb: &Strong<dyn ICallback>) -> BinderResult<()> { self.upstream.register(&self.callbacks.wrap(cb)) } fn unregister(&self, cb: &Strong<dyn ICallback>) -> BinderResult<()> { self.upstream.unregister(&self.callbacks.wrap(cb)) // the same object } }
It holds only weak references, so it never keeps a wrapper alive: the wrapper lives as long as C holds it, and the entry goes when the remote dies or the wrapper is dropped.
What a gateway costs, all of it visible in B:
- Identity. C sees B as its caller, not A. C's
@EnforcePermissionchecks pass with B's credentials, so authenticating A is B's job —ServeOptions::authorizeron B's endpoint. - File descriptors. They only cross a Unix-domain link, and only with FD passing negotiated on that hop.
- Uid. Over TCP or vsock, B cannot learn A's uid at all.
- Plaintext TCP is bring-up only; a real deployment uses TLS or a Unix socket.
- Death. There are now two lifetimes. A's proxy dies when B goes away; B's upstream dies when C does. B does not forward one as the other.
- Threads. Each forwarded call occupies one of B's RPC workers for the
whole upstream round trip — size
ServeOptions::threadsaccordingly.
example-hello ships a runnable one:
cargo run -p example-hello --bin hello_service # C, on kernel binder
cargo run -p example-hello --features rpc --bin gateway_service \
binder://my.hello unix:///tmp/rsb_gw.sock # B
cargo run -p example-hello --features rpc --bin unified_client \
unix:///tmp/rsb_gw.sock # A
What the Uri does not hide
Moving a service between transports changes its trust boundary — read Security & Authorization.
binder:// | RPC schemes | |
|---|---|---|
@EnforcePermission | enforced via the system permission service | denied (no caller-uid model) — authorize with ServeOptions::authorizer |
get_calling_uid() | kernel-vouched | kernel-vouched on unix://, fail-closed sentinel elsewhere |
| death | process death | session disconnect |
list_services, notifications, lazy services | via hub | not available |
| abandoning a session | nothing to do | call RpcSession::close_session() if a service you exported holds a proxy back into it (client.session()) |
Transport capabilities
The differences above are enforced where they happen: writing a file
descriptor into a parcel that cannot carry one fails at the write, and
get_calling_uid() returns a sentinel that no ACL matches. Those answers
arrive at the moment of use, which is late if you were about to promise a
caller something the transport cannot do.
TransportCaps is the same information available up front. Ask before
you commit:
use rsbinder::TransportCaps;
let client = rsbinder::Client::open(rsbinder::Endpoint::unix("/run/app.sock"))?;
// Fails here, logging which bits are missing and when each one holds,
// instead of on the first callback the server tries to make.
client.caps().require(TransportCaps::CALLBACKS, "event subscription")?;
Read it from Client::caps(), RpcSession::caps(), Endpoint::static_caps(),
or — inside a handler, for the call being served — calling_caps().
binder:// | unix:// | vsock:// | tls:// | |
|---|---|---|---|---|
FD_PASSING | ✅ | after negotiating Unix fd mode | ❌ | ❌ |
TRUSTED_UID | ✅ | ✅ | ❌ | ❌ |
CALLBACKS | ✅ | with incoming_connections > 0 | with incoming_connections > 0 | with incoming_connections > 0 |
SAME_HOST | ✅ | ✅ | ❌ | ❌ |
KERNEL_KNOBS | ✅ | ❌ | ❌ | ❌ |
Three things to keep straight.
Caps never replace the checks they summarize. Skipping the check is not less safe; the fd write still refuses, the uid is still a sentinel. What you lose is being told early, and being told when the missing bit holds.
Endpoint::static_caps() and the session's caps answer different
questions. The endpoint answers for the transport family; the session says
what it has. The endpoint bounds the session in neither direction: a unix://
endpoint reports FD_PASSING, but a session over it carries no descriptors
until both ends negotiate the Unix fd mode — the default is None; and no
RPC endpoint reports CALLBACKS (the binder:// column does, because kernel
binder has every bit), although a session opened with
incoming_connections > 0 does. The table's two qualified cells are session
values for that reason, not endpoint ones.
A session's caps are a snapshot. CALLBACKS goes away when the last
callback connection dies. Read them where you act, not once at startup.
Async
Under the tokio feature, rsbinder::connect_async::<dyn IHelloAsync<Tokio>>(uri).await
runs the connect on spawn_blocking. Async servers pass
BnHello::new_async_binder(impl, TokioRuntime(handle)) to add and use
spawn() (never run()) from inside a runtime — see
Async Service.
Worked example
example-hello ships bin/unified_service.rs / bin/unified_client.rs:
identical service, registration, and call code with the transport chosen by
the argument (kernel, rpc, or any URI string, parsed into a Uri).
cargo run -p example-hello --features rpc --bin unified_service rpc
cargo run -p example-hello --features rpc --bin unified_client rpc
cargo run -p example-hello --features rpc --bin unified_client unix:///tmp/rsb_unified.sock
Security & Authorization
A service handler often needs to answer "who is calling, and are they allowed to?" rsbinder gives you the caller's identity and several ways to authorize — but the right tool differs by transport, and that difference is deliberately made explicit rather than hidden.
Core principle. Kernel binder and RPC have genuinely different trust boundaries. Kernel binder gives you a kernel-vouched uid/pid and SELinux context; RPC's trust boundary is the transport itself (Unix peer credentials, a TLS certificate, or hypervisor VM isolation). rsbinder never papers over this: an authorization check that is safe on kernel binder either keeps working, fails closed, or is denied over RPC — never silently weakened.
Caller identity inside a handler
The calling context is ambient thread-local state, set by the dispatch
machinery just before your handler runs and cleared just after (this
mirrors AOSP's IPCThreadState). So inside a transact handler you read
it with no parameter threading:
#![allow(unused)] fn main() { impl IExample for MyService { fn do_thing(&self, arg: i32) -> rsbinder::BinderResult<()> { let uid = rsbinder::get_calling_uid(); // who is calling, right now // ... authorize, then act ... Ok(()) } } }
The accessors (all rsbinder::…):
| Accessor | Returns | Works over |
|---|---|---|
get_calling_uid() | caller effective uid | kernel binder, Unix RPC |
get_calling_pid() | caller pid | kernel binder, Unix RPC |
get_calling_sid() | SELinux context (Option<CString>) | kernel binder only (RPC → None) |
is_handling_transaction() | bool | kernel binder, RPC |
calling_caller() | Option<Caller> (transport-tagged) | kernel binder, RPC |
These are only meaningful inside a handler, on the dispatching thread.
Outside a transaction get_calling_uid() / get_calling_pid() return this
process's own uid and pid (getuid() / getpid()), get_calling_sid()
returns None and calling_caller() returns None — as AOSP's
IPCThreadState does. A thread you spawn inside a handler has no calling
context, so it reads this process, not the caller: do the check on the
dispatching thread and pass the result along. An in-process call to a
local service reads the same way — this process calling itself.
get_calling_sid() is Some only for a binder built with
BinderFeatures::set_requesting_sid (or the context manager), and only where
SELinux is available: on Android, or on a Linux host with
/sys/fs/selinux/enforce. Elsewhere rsbinder does not ask the kernel for the
context at all — a kernel with no LSM that can produce one fails every
transaction to a binder that asks — and the sid is None.
Fail-closed values, never fabricated ones
get_calling_uid() returns a real uid only where the transport carries
one — kernel binder, and Unix-domain RPC (SO_PEERCRED on Linux/Android,
getpeereid on macOS/BSD). Over a transport with no uid (vsock, TLS
certificate, anonymous) it returns the sentinel u32::MAX, which is
never 0 (root) and never a real privileged uid:
#![allow(unused)] fn main() { // Over vsock / TLS / anonymous, get_calling_uid() == u32::MAX: if rsbinder::get_calling_uid() == 0 { // u32::MAX != 0 ⇒ never matches // ... "allow root" ... // so a uid-less peer can't slip through } if rsbinder::get_calling_uid() != AID_SYSTEM { return Err(Status::from(ExceptionCode::Security)); // uid-less peer ⇒ denied } }
This is intentional: fabricating a uid (say 0) for a transport that
doesn't have one would be a privilege-escalation hole. uid ACLs simply
fail closed over uid-less transports — use a transport-appropriate check
there instead (see below).
The transport-tagged caller
For anything beyond a plain uid check, match on
Caller, which
tags the identity by transport so you authorize explicitly:
#![allow(unused)] fn main() { use rsbinder::{Caller, ExceptionCode, Status}; use rsbinder::rpc::PeerIdentity; fn authorize() -> rsbinder::BinderResult<()> { match rsbinder::calling_caller() { // Kernel binder: Android permission, uid, or SELinux `sid`. Some(Caller::Kernel { uid, .. }) if uid == 1000 => Ok(()), // Unix RPC: kernel-vouched peer uid. Some(Caller::Rpc(PeerIdentity::Local { uid, .. })) if uid == 1000 => Ok(()), // TLS RPC: certificate subject allowlist. Some(Caller::Rpc(PeerIdentity::Certificate(cert))) if cert.subject() == "CN=trusted-client" => Ok(()), // vsock cid, anonymous, no transaction, or a future variant: // fail closed. _ => Err(Status::from(ExceptionCode::Security)), } } }
The arm is the transport of the transaction being served now, not of the one
that started the thread. An RPC handler that makes an outgoing kernel call can
have a kernel transaction dispatched inside it (binder's nested IPC); inside
that inner handler calling_caller() is Caller::Kernel, and the uid, pid
and sid getters report the kernel caller, until the inner transaction
returns.
Caller and PeerIdentity are #[non_exhaustive], so the compiler forces
a catch-all arm — which nudges you toward a fail-closed default.
Identity stops at a bridge
A gateway or Accessor re-publishes an
upstream service on another transport, and the caller identity does not
travel with the call. In A → B → C, C's handler sees B — B's uid, B's
certificate, B's sid. C's @EnforcePermission checks therefore pass with
B's credentials, and a uid allowlist in C authorizes B, not A.
That makes B the trust boundary. If C is meant to be reachable only by
certain callers, B has to enforce it, on B's own endpoint — via
ServeOptions::authorizer, or a calling_caller() check in the forwarding
method. A gateway that forwards unconditionally widens C's exposure to
everyone who can reach B, which over TCP or vsock is everyone who can reach
the port, since B cannot learn A's uid there at all.
Four ways to authorize
| When | Use |
|---|---|
| uid check, common to kernel + Unix RPC | get_calling_uid() |
| different rule per transport (uid / cert / cid / sid) | match calling_caller() |
| Android permission on a kernel service | @EnforcePermission(...) (declarative) |
| one policy across many methods / introducing RPC authz | PermissionAuthority (injected) |
@EnforcePermission (declarative, kernel-only)
Annotate the AIDL method and the generated code checks Android's
PermissionManagerService before your handler runs — no authorization
code in the body:
interface IExample {
@EnforcePermission("android.permission.INTERNET")
void doNetworking();
}
Over RPC this method is denied (returns EX_SECURITY), unconditionally
and regardless of process shape. Android permissions are a kernel-binder
concept; the RPC dispatch path has no PMS-backed uid, so granting would
mean granting root to any anonymous peer. rsbinder fails closed instead.
For RPC authorization use the transport-native means above.
PermissionAuthority (injected, cross-transport policy)
To back every @EnforcePermission check with one centralized,
transport-aware policy — for example to introduce authorization over RPC
that the default denies — install a
PermissionAuthority
at startup:
#![allow(unused)] fn main() { use rsbinder::Caller; use rsbinder::permission_controller::{PermissionAuthority, set_permission_authority}; use rsbinder::rpc::PeerIdentity; use std::sync::Arc; struct MyPolicy; impl PermissionAuthority for MyPolicy { fn check(&self, permission: &str, caller: &Caller) -> bool { match caller { Caller::Rpc(PeerIdentity::Local { uid, .. }) => *uid == 1000, Caller::Rpc(PeerIdentity::Certificate(c)) => c.subject() == "CN=admin", _ => false, // fail closed } } } set_permission_authority(Arc::new(MyPolicy)); }
When installed, the authority owns the whole decision for every
generated check_permission call, on every transport, receiving the
transport-tagged Caller. With no authority installed, the default is
unchanged: kernel → PMS, RPC → deny.
The core crate ships only this slot, never a policy. Token/JWT formats, certificate→permission tables, and uid→permission maps are deployment concerns. Caveat: an installed authority also replaces the kernel PMS path — if you want "kernel = PMS, RPC = custom", handle the
Caller::Kernelarm explicitly (most deployments that inject an authority are pure-RPC, where that arm never fires).
Connection-level authorization (RPC)
For RPC, the most natural granularity is often the whole connection,
decided at handshake before any transaction — this is the right tool for
vsock and TLS, which carry no per-call uid. Use
RpcServer::set_authorizer:
#![allow(unused)] fn main() { server.set_authorizer(|peer| match peer { rsbinder::rpc::PeerIdentity::Certificate(cert) => cert.subject() == "CN=trusted", rsbinder::rpc::PeerIdentity::Vsock { cid } => *cid == TRUSTED_VM_CID, _ => false, }); }
A rejected peer's socket is closed before any RPC byte is exchanged.
Worked example
example-hello ships a runnable handler-authorization demo —
bin/authz_service.rs / bin/authz_client.rs:
cargo run -p example-hello --features rpc --bin authz_service
cargo run -p example-hello --features rpc --bin authz_client
# whoami -> OK: unix-rpc caller uid=1000 pid=12345
# adminOnly -> DENIED (Security)
whoami() authorizes any identifiable local caller and reports it;
adminOnly() requires uid 0 and denies a normal user — demonstrating both
the allow and the fail-closed deny path.
Summary
- Read the caller with
get_calling_uid/pid/sidandcalling_caller()— ambient thread-local state, valid only inside a handler. - uid ACLs work on kernel binder and Unix RPC, and fail closed
(
u32::MAXsentinel) on uid-less transports. @EnforcePermissionis kernel-only and denies over RPC.- Use
match calling_caller()for transport-aware rules,set_authorizerfor connection-level RPC policy, andPermissionAuthorityto centralize. - The default everywhere is fail-closed; weakening a boundary is always an explicit, opt-in choice.
Stability tiers
rsbinder uses a three-tier model for public-API stability on the path to 1.0. Each tier states what kind of change can land between releases and what callers can rely on.
| Tier | Versioning | Wire compatibility | When it can change |
|---|---|---|---|
| Stable | Semver-strict | AOSP-faithful, byte-tested against real libbinder | Only on a major bump — and pre-1.0 the 0.x minor slot is the major slot (see the changelog preamble). Breaking changes are last-resort. |
| Provisional | Stabilizing toward 1.0 | AOSP-faithful in the validated cases (hermetic + real-libbinder STAGE3) | May break in a minor bump if 1.0 review surfaces an issue. Will be re-classified as Stable or Experimental at 1.0. |
| Experimental | Opt-in via Cargo feature | No guarantee until the corresponding real-libbinder interop gate passes | Anytime, including the wire format. Default builds do not reach this code. |
Stable
Surface that has shipped through multiple releases against real Android binder and is not expected to break.
- Kernel binder core —
Parcel,IBinder,Interface,Strong<I>,Weak<I>,Status,StatusCode,ParcelFileDescriptor,ProcessState::{init, init_default, start_thread_pool, join_thread_pool},Binder::new,BinderFeatures. - Transaction code constants —
FIRST_CALL_TRANSACTION,LAST_CALL_TRANSACTION,PING_TRANSACTION,INTERFACE_TRANSACTION,DUMP_TRANSACTION,FLAG_ONEWAY,FLAG_CLEAR_BUF. - Service manager (Android binder) —
hub::add_service,hub::check_service. (hub::get_service/hub::get_interfacewere deprecated in 0.10.0 and removed in 0.11.0, in favor ofwait_for_*/check_interface/try_*— which are Provisional (below). See Service Manager.) - AIDL compiler entry points —
rsbinder_aidl::Builder::{new, source, output, version, generate}. - rsb_device binary CLI (binderfs setup);
--group/--modeand the0600default node are new in 0.11.0.
Provisional
Implemented and validated, but the public-API shape is still under review for 1.0. Expect at most a single round of renames or signature tweaks; wire formats are already locked.
- Service-manager lookups (new in 0.10.0) —
hub::wait_for_service,wait_for_interface,check_interface,try_get_service,try_get_interface,register_client_callback,try_unregister_service, and theimpl Into<SIBinder>form ofadd_service. Wire behavior matches AOSPwaitForService/checkService; only the Rust signatures are still settling. - RPC transport —
RpcServer,RpcSession,setup_unix_server,setup_unix_client*,from_preconnected_fd,set_root,get_root,set_android13plus,set_max_threads(N)(bothN == 1andN >= 2multi-connection),set_max_connections,set_supported_fd_modes(&[FileDescriptorTransportMode::Unix]). Validated against real android-13 / 14 / 15 / 16libbinder(plans 2-1 … 2-12 / 2-13 / 2-14 STAGE3). - FD-over-RPC (v1+ AOSP-faithful, AC-11.3 gate passed).
- RPC death notification (session-disconnect, AOSP-faithful).
- IAccessor client / server (plans 2-13 D.8 + 2-14 D.9 STAGE3
passed against real
libbinder). rsb_hub—addServicedescriptor auto-detect,getService2,checkService2,Service::Accessorrouting. Linux-native bringup. New in 0.11.0: per-name access control, service declarations, on-demand start,dump, supervisor integration, and thersb_serviceCLI — the configuration file format is settling with them.- macOS first-class support (plan 2-9 Phase A+B).
- Entry API (new in 0.11.0) —
serve,connect,connect_async,Client,Server,ServerGuard,ServeOptions,ClientOptions,Endpoint. Replaced thersbinder::servicefacade. The URI grammar and the two option structs are what may still change. - Interface macros (new in 0.11.0,
macrosfeature) —#[rsbinder::interface],#[derive(Parcelable)],#[derive(BinderEnum)]. Feature-gated but not Experimental: the emitted code is byte-for-byte the.aidlcode, so the wire is whatever.aidlalready guarantees. Only the Rust-facing spelling is provisional; 0.12.0 narrowed it to what.aidlrenders and added#[deprecated]. See Interface Macros. - Shared memory (new in 0.11.0) —
SharedMemory,MemoryHeapBase,MappedHeap,MemoryDealer,HeapCache, and theIMemory/IMemoryHeapwire (handwritten AOSP wire, not AIDL). STAGE3-gated against reallibbinderin both directions. - Data serialization (new in 0.11.0) —
to_bytes/from_bytes, no feature required since 0.12.0. The bytes are the IPC bytes, so only the two signatures may still change. See Storing Values. - Cross-stack bridging (new in 0.11.0) —
impl Interface for Strong<I>(a proxy publishes as a local service, so a gateway is one line) andbridge::Rewrap. See Cross-Transport Services. lazy_service(new in 0.11.0) —LazyServiceRegistrarand the AOSPregisterService/ client-callback lifecycle, gated against Android's ownservicemanager.- Receive mapping size (new in 0.12.0) —
ProcessState::init_with_mmap_size,binder://?mmap=,ClientOptions::mmap_size/ServeOptions::mmap_size. The size is local to the receiving process and nothing about it crosses the wire. TransportCaps(new in 0.12.0) —Client::caps(),RpcSession::caps(),Endpoint::static_caps(),calling_caps(). Each bit is derived from a check another type still performs, so the set of bits is what may grow.- Cancellation (new in 0.12.0) — the
cancelmodule over AOSP'sandroid.os.ICancellationSignal, STAGE3-gated against reallibbinderin both directions. - Typed service-specific errors (new in 0.12.0) —
ServiceSpecificError,#[derive(ServiceSpecificError)],impl_service_specific_error!,Status::service_specific,Status::service_error,Status::message. The code on the wire is thei32AOSP writes; only the Rust mapping is new. - Large payloads and pipes (new in 0.12.0) —
Parcel::write_blob/read_blob(AOSP's blob wire, inline up to 16 KB and a memfd region above),ParcelFileDescriptor::pipe()and itsRead/Writeimpls. - Awaitable lookups and death (new in 0.12.0,
tokiofeature) —wait_for_interface_async,check_interface_async,death_signal/DeathSignal. Wire behavior is that of the synchronous calls they wrap. - Fast Message Queue (new in 0.12.0) — the
rsbinder-fmqcrate (MessageQueue,EventFlag,Descriptor,AttachPolicy; the synchronized flavor, primitive elements, Linux and Android) andrsbinder::fmq, which re-exports it on every platform beside the AOSP parcelablesMQDescriptor,GrantorDescriptor,SynchronizedReadWrite,UnsynchronizedWriteandNativeHandle, with the twoTryFromconversions betweenMQDescriptorandDescriptor.rsbinder-aidlresolves imports ofandroid.hardware.commonandandroid.hardware.common.fmqto those types and compiles generic parcelables such asMQDescriptor<T, Flavor>. The parcelables are AOSP's VINTF-stable wire, and the queue keepslibfmq's memory layout, counters and futex protocol, so only the Rust signatures may still change. For an NDK peer, which cannot linklibfmq, the reference implementation is two header-only C11 files:rsbinder-fmq/c/rsbinder_fmq.h(the queue) andcontrib/ndk/rsbinder_stream.h(the stream's records on it). Validated against AOSP's ownlibfmqon the host, both roles (rsbinder-fmq/tests/libfmq_host.rs), and across binder on an emulator with either side as the service (example-hello/cpp/run_fmq_interop.sh); the queue header against the crate on the host (rsbinder-fmq/tests/c_header.rs), the record header with both ends under AddressSanitizer (tests/tests/c_stream_header.rs), and both against rsbinder on an emulator (example-hello/cpp/run_stream_interop.sh). See Builtin AOSP Types and Generic Parcelables. - Streaming (new in 0.12.0) — the
streammodule:Sink,Receiver, and the three AIDL files inrsbinder/aidl/stream/: thersbinder.stream.StreamEndpoint<T>parcelable (Tthe item type; the ring's record layout and EventFlag bits on the kernel path) and the two interfacesIStreamSink/IStreamSource(the RPC path). The contract is rsbinder's own rather than AOSP's, written as ordinary AIDL so a C++ or NDK peer can be either end (a Java peer is out of scope: AOSP has no Java FMQ). Validated rsbinder-to-rsbinder over kernel binder (two processes, including a real Android driver) and over RPC, and over kernel binder against an NDK peer built on the C reference headers and against one whose ring is AOSP's ownlibfmq, both directions (example-hello/cpp/run_stream_interop.sh), and over RPC against the device's libbinderRpcSession, both directions (example-hello/cpp/run_stream_rpc_interop.sh). See Streaming. - Observability (new in 0.12.0) — the work source functions
(
set_calling_work_source_uidand the five beside it, the AOSPIPCThreadStateset),Remotable::transaction_namewithrsbinder_aidl::Builder::trace, theobservemodule (TransactionObserver,set_observer,LogObserver,StatsObserver), and thetracingfeature (TracingObserverand the client spans of proxies generated withtrace(true)). None of it changes the wire: the work source uses a header field kernel binder already has. The work source is STAGE3-gated against reallibbinder. See Observability. - Reconnecting (new in 0.12.0) —
Reconnectingand thereconnectmodule: one service named by aUri, looked up again after its process dies or its RPC session ends, with anon_connecthook rerun on each new connection. It adds nothing to the wire. On kernel binder it sends, beyond a plainconnect, a death link and, while the name is absent, a service-manager notification registration, both existing commands; over RPC it sends nothing a plainconnectdoes not. Validated over kernel binder and RPC, and against a reallibbinderRpcServerthat is killed and restarted on an emulator (example-hello/cpp/run_rpc_reconnect_interop.sh). See Reconnecting to a Service. - Freeze notifications (new in 0.12.0) —
IBinder::add_frozen_state_change_callback/remove_frozen_state_change_callback,FrozenStateChangeCallback,FrozenState, andProcessState::freeze_process/process_freeze_info: AOSPaddFrozenStateChangeCallback(libbinder android-15.0.0_r6+) over the driver's own commands, so the contract is the kernel UAPI rather than a libbinder wire. Validated on the Rust binder driver, on the C driver of an Android 17 emulator, and on an Android 15 driver withoutfeatures/freeze_notification, whereadd_frozen_state_change_callbackreturnsInvalidOperation(tests/tests/freeze_kernel.rs). RpcClientConfig(new in 0.12.0) — one android-13+ client configuration for every transport (unix,unix_abstract,vsock,tls,tcp_debug,new), withincoming_connectionsandoutgoing_connectionson all of them. It replacesRpcUnixClientConfigand the Unix-only multi-connection helpers, which are deprecated and go in the release after.- AIDL compiler, beyond the Stable entry points —
Builder::{dest_dir, hash}and the publicrendermodule, the seamrsbinder-macrosplugs into. Its input structs are#[non_exhaustive], so a template gaining a field stays a minor release.
Experimental (opt-in)
Off by default, gated by a Cargo feature. Wire format and API may change without a deprecation cycle.
| Feature | Surface | Status |
|---|---|---|
rpc-tcp-debug | TcpDebugTransport — plain TCP backend | Bring-up / interop only, never production. |
rpc-vsock | VsockTransport — host↔VM | Linux/Android only; loopback testing requires vsock_loopback.ko. |
rpc-tls | TLS over rustls, socket-orthogonal (tcp / unix / vsock) | Hermetic green; the StreamOwned decoupling (plan 2-15) has landed. Real-libbinder STAGE3 not yet attempted — stock emulator images ship no libbinder_tls. |
Non-goals
Surface that rsbinder will not support, even after 1.0. Listed here so contributors don't burn cycles implementing something that has already been ruled out — and so the AIDL compiler's deliberate rejection of these types reads as intentional rather than as a gap.
- AIDL
Map<K, V>— AOSP's own Rust / C++ / NDK backends rejectMap<K, V>at the language layer (aidl_language.cpp:1612-1615: "Currently, only Java backend supports Map."), and have done so since 2019. The wire format is built on JavaParcel's runtime type-tag system (VAL_STRING,VAL_INTEGER,VAL_MAP, …) which has no typed-Rust analogue. The rsbinder-aidl frontend therefore emitsunknown type 'Map'for anyMap<…>reference — matching AOSP. Useparcelable(typed struct),List<Entry>, orParcelableHolderinstead. Re-examine if AOSP removes the cross-backend block.
What this means in practice
- A 0.x → 0.(x+1) minor bump can change Provisional signatures.
Pre-1.0 the minor slot is the major slot, so a Stable item can
change there too — what the tier buys you is that it is last-resort and
always lands in that release's Migrating from … section of the
CHANGELOG,
named with its replacement. That section carries the changes that have a
replacement to name; one that only alters a value or a behavior may be
recorded under Fixed instead. Changed / Removed carry the rationale
for the entries that have one, but not every entry appears there.
rsbinder runs
cargo-semver-checksfor bothrsbinderandrsbinder-aidl(default features and RPC features) on pull requests targetingmaster; it reports signature changes, and cannot see a change that alters only a value or a behavior. Neither can your build — for those, read Migrating and Fixed both. - Experimental features never break the default build's wire format. Enabling one is an explicit acknowledgement that the corresponding interop gate has not passed.
- After 1.0, all Stable items follow Semver strictly. Provisional items get re-classified as Stable or moved into a feature gate.
Migrating from 0.11 to 0.12
Every change in 0.12.0 that can break a program built against 0.11.0: a compile error, a different value on the wire, or behavior a running program can observe. Entries are grouped by area; within an area, those that change the wire say so and need both peers upgraded together.
The additions and fixes themselves are listed in
CHANGELOG.md;
a reference such as "(CHANGELOG Fixed)" points to the 0.12.0 section there.
Toolchain and features
- Minimum supported Rust version is now 1.86 (was 1.85). The local-binder
cast
Binder::<B>::try_from(SIBinder)(behindFromIBinder::try_fromandSIBinder::into_interface) now downcasts theArcthrough trait upcasting instead of reinterpreting a raw pointer, which needs 1.86. Results are unchanged: a remote proxy, anotherRemotabletype, or anIBinderwrapper that forwardsas_any()to a local binder still fails withBadValue. - Android builds must link for API 29 (Android 10, the oldest supported
platform) or newer: pass
--platform 29tocargo ndk. rsbinder now calls bionic'sprocess_vm_readv, which cargo-ndk's default API 21 does not export, so a build without it fails withundefined symbol: process_vm_readv. 0.11.0 linked at API 21. - The
tokiofeature no longer enablestokio/full, onlytokio/rt,tokio/rt-multi-threadandtokio/sync(fordeath_signal). A crate that receivedtokio/net,time,signal,fs,io-utilormacrosthrough rsbinder must declare them in its ownCargo.toml. BinderAsyncRuntime for TokioRuntime<Runtime>andfor TokioRuntime<Arc<Runtime>>are gone (an owned runtime in a binder panics inRuntime::dropwhen the lastStrongdrops on a worker). Wrapruntime.handle().clone()inTokioRuntime<Handle>instead.
Status codes
StatusCode::from(ExceptionCode::ServiceSpecific)isServiceSpecific(0), notOk, as in AOSP;StatusCode::from(status)for such aStatusnow yieldsServiceSpecific(0), notFailedTransaction.- On a host whose errno numbering is not Linux asm-generic (Apple
platforms, and Linux on SPARC, MIPS, Alpha or PA-RISC) the status codes on
the wire changed; upgrade every such peer together. 0.12.0 sends AOSP's
values (
utils/Errors.hin Linux asm-generic errno numbering) on every host (CHANGELOG Fixed). Between a 0.11.0 and a 0.12.0 macOS peer six named codes decode differently: a 0.11.0 peer reads 0.12.0'sInvalidOperation,UnknownTransaction,BadIndex,NotEnoughData,WouldBlockandTimedOutasErrnovalues (NotEnoughData's-61as DarwinECONNREFUSED), and a 0.12.0 peer reads 0.11.0's asUnknown. AStatusCode::Errnono longer crosses between two such peers either: it arrives asUnknown. The same holds for aStatusCodeserialized into aParcel. Between two peers with asm-generic numbering (Linux and Android on every other architecture) these codes do not change. - On a host whose errno numbering is not Linux asm-generic
StatusCode::from(i32)andi32::from(StatusCode)convert wire values, not host errnos.StatusCode::from(-libc::ECONNREFUSED)isNotEnoughDataon macOS, since-61is AOSPNOT_ENOUGH_DATA;StatusCode::from(-libc::ECONNRESET)isUnknown, andi32::from(StatusCode::Errno(e))isUNKNOWN_ERROR(i32::MIN). Build a status from an errno withFrom<rustix::io::Errno>orFrom<std::io::Error>, which keepStatusCode::Errnoin the host's numbering. On a host with asm-generic numbering both conversions are unchanged apart from the sign rule in the next entry. - A handler's
Err(StatusCode::Errno(x))withx >= 0, orErr(StatusCode::ServiceSpecific(v))withv <= 0, now answersUnknownon every host (CHANGELOG Fixed), where it answered success (0) or a status the client decoded as another variant.i32::fromand aStatusCodewritten into aParcelfollow the same rule. Send a zero or negative service-specific code inside aStatus(Status::new_service_specific_error,Status::service_specific), which carries it unchanged. ExceptionCode::from(StatusCode::UnexpectedNull)isTransactionFailed, notNullPointer, as AOSPStatus::setFromStatusTmaps every non-zerostatus_t. A handler returningErr(StatusCode::UnexpectedNull)now answers with that transaction status instead of anEX_NULL_POINTERexception header: the client'sStatushasexception_code() == TransactionFailedandtransaction_error() == UnexpectedNull, and a branch onExceptionCode::NullPointerno longer runs.- A service handler's
Err(StatusCode::DeadObject)reaches a remote caller asFailedTransaction, over kernel binder and over RPC; every other status goes out asi32::fromencodes it. An AOSPBpBinderthat receivesDEAD_OBJECTas a reply status marks itself dead (mAlive = 0,BpBinder::transact) and fails every later call on that proxy without sending it, so one handler error cut the caller off from a service that was still running.DeadObjectreaches a handler as an ordinary value:?on a nested call to a dead binder, or on aBrokenPipewrite error (EPIPEisDEAD_OBJECT), as in anon_dumpwhose reader exited (dumpsys foo | head). The substitution also covers aStatuswithExceptionCode::TransactionFailedandDeadObjectas its transaction error, which a generated stub sends as the reply status. A local (in-process) call still returns the handler'sErrunchanged; a client that matchedDeadObjectfrom a live rsbinder service now seesFailedTransaction. FLAG_COLLECT_NOTED_APP_OPSis0x2, not0x80(CHANGELOG Fixed): a flag value sent on the wire changes, so a 0.11.0 peer testing or setting it disagrees with a 0.12.0 one; rebuild both sides.
Entry API (serve / connect)
-
serve,connect,connect_binder,connect_async,Client::openandClient::open_withtakeimpl Into<Uri>, not&str(CHANGELOG Changed). Build the destination with constructors, or parse a string explicitly:#![allow(unused)] fn main() { // 0.11.0 rsbinder::serve("binder://")?; let h: Strong<dyn IHello> = rsbinder::connect("unix:///tmp/x.sock#hello")?; let h: Strong<dyn IHello> = rsbinder::connect(&config.target)?; // 0.12.0 rsbinder::serve(Uri::kernel())?; let h: Strong<dyn IHello> = rsbinder::connect(Endpoint::unix("/tmp/x.sock").with_service("hello"))?; let h: Strong<dyn IHello> = rsbinder::connect(config.target.parse::<Uri>()?)?; }The schemes and query keys are the same, but a malformed string now fails at
parsewith aUriErrorinstead of atserve/connect;?intorsbinder::Resultstill yieldsStatusCode::BadValueand logs the reason. Replace a URI built withformat!("unix://{}", path.display())byEndpoint::unix(&path): the string form is percent-decoded, so a path holding%,#or?named a different socket or failed to parse. -
Endpointchanged shape.Endpoint::Kernel(KernelEndpoint)replaces the 0.11.0 struct variantKernel { driver, threads }; read the settings withKernelEndpoint::driver()/threads()/mmap_size()and build one withKernelEndpoint::default().with_threads(..).Endpoint::Vsock { cid, port }andEndpoint::Tls { host, port }replace the tuple variants; build them withEndpoint::vsock(cid, port)andEndpoint::tls(host, port). -
Uri's fields are private: useendpoint(),service()andwire().wire_max_version: Option<u32>is now aWireProfile—R34, orAndroid13Plus(WireVersion)for?profile=android13plus[-vN].rsbinder::entry::uri::parseis removed; useUri::parseorstr::parse. -
A
tls://host is percent-decoded, its[ ], if any, must be one pair enclosing the whole host, and a host holding:needs them. 0.11.0 took the host text literally, split it from the port at the last:and stripped any number of brackets from its ends:tls://[[::1]]:9000,tls://[::1:9000andtls://::1:9000reached::1,tls://a]:9000reacheda, andtls://fe80::1, with no port, became hostfe80:and port 1. All of these are nowUriErrorKind::BadBrackets; writetls://[::1]:9000. Write an IPv6 zone id as%25(tls://[fe80::1%25eth0]:9000):tls://[fe80::1%eth0]:9000is nowBadPercentEscape, and a numeric zone such as%12decodes to a control character instead of reaching the resolver. A client connecting to a host with a zone id must also setClientOptions::tls_server_name: the host is the default TLS server name, a server name cannot hold a zone id, and without it the connect fails withStatusCode::RpcError.tls://[]:9000, which 0.11.0 took as an empty host, isEmptyHost. -
A query key given twice is refused (
UriErrorKind::DuplicateQueryKey). 0.11.0 used the last value, sounix:///a?profile=android13plus-v2&profile=android13plus-v0spoke v0. -
A
?queryafter#serviceis refused (UriErrorKind::QueryAfterService). 0.11.0 made it part of the service name:binder://#svc?driver=/dev/xlooked up"svc?driver=/dev/x"on the default driver. Put the query first. -
A
unix://path or?driver=may hold percent-escaped bytes that are not UTF-8; 0.11.0 refusedunix:///tmp/%FF.sock. A kernel driver path that is not UTF-8 parses but isBadValueatserve/connect, sinceProcessStateopens the driver by a&str. -
?profile=android13plus-vNaccepts any decimal spelling ofN, so-v02is version 2; 0.11.0 matched only-v0,-v1and-v2. -
A kernel
Server::addno longer registers the name;run/spawndo, in this order: check theServeOptions, start the thread pool, register the names. 0.11.0 registered atadd, so an option refused atrun/spawnleft the name published with no thread to serve it, and its callers blocked. Two consequences:-
A readiness signal printed between
addandrunnow comes before the name exists, and a client that looks it up once (check_service) can miss it. Signal afterspawninstead:#![allow(unused)] fn main() { // 0.11.0 let server = rsbinder::serve(Uri::kernel())?.add(NAME, svc)?; println!("READY"); server.run() // 0.12.0 let _server = rsbinder::serve(Uri::kernel())?.add(NAME, svc)?.spawn()?; println!("READY"); ProcessState::join_thread_pool() } -
A registration error — a name the service manager refuses (invalid, or held by another uid under
rsb_hub), a policy denial, an RPC proxy — is returned byrun/spawn, notadd. Names registered before the failing one stay published and served until the process exits.
-
Kernel binder, Parcel and process state
- A kernel option
serve/Client::opencannot honor is nowBadValue. TheKernelEndpointthread count and driver (binder://?threads=,?driver=),ServeOptions::threadsandClientOptions::driverare fixed by whoever initializesProcessStatefirst; a later different value used to log a warning and now returnsStatusCode::BadValue(omitting it or repeating the value still works). LikewiseClientOptions::driver/mmap_sizemust agree with theKernelEndpoint's driver and mapping size. A driver path that is not UTF-8 isBadValuetoo; 0.11.0 opened its lossy UTF-8 conversion, a different path. ServerGuardandServerare#[must_use]:serve(uri)?.spawn()?;stopped an RPC server at once. Bind the guard to a named variable.- A kernel transaction dispatched inside an RPC handler now reports the
kernel caller:
calling_caller(),get_calling_uid()/pid()/sid()andCallingContext::default()returnCaller::Kerneland the kernel sender's values, not the suspended RPC peer's. Re-check handlers that pick an authorization rule bymatching on theCallerarm. - Outside a transaction
get_calling_uid()/get_calling_pid()return this process's own uid and pid instead of0(root), as AOSP'sIPCThreadStatedoes. Inside a transaction nothing changes. ProcessState::set_call_restrictionnow restricts the calling thread too (CHANGELOG Fixed). A thread that had already made a binder call kept its previous value; it now takes the new one at once, so a twoway call it makes afterwards logs an error (ErrorIfNotOneway) or panics (FatalIfNotOneway). The main looper ofServeOptions::call_restrictionis such a thread: a handler running on it that makes a twoway call now hits the restriction.- A kernel
Client::get/connectreports the service manager's own failure instead ofNameNotFoundwhen it cannot be reached, andhublogs why once. get_calling_sid()isNonewhere SELinux is not available (CHANGELOG Fixed): on Linux without a mounted selinuxfs, Smack and AppArmor hosts included, a binder withBinderFeatures::set_requesting_sidno longer asks the driver for the caller's context. Under Smack or AppArmor 0.11.0 returned the label, read on to the next NUL byte.Parcelis no longerRefUnwindSafewithout therpcfeature: a kernel parcel now holds the binder proxies written into it (CHANGELOG Fixed). Withrpcit already was not. Wrap a&ParcelinAssertUnwindSafewherecatch_unwindneeds it.- A parcel write over a binder or fd object it recorded is
PermissionDenied(CHANGELOG Fixed): rewinding withset_data_positionand writing over an object fails where 0.11.0 overwrote it. Parcel::from_ipc_partshas one more# Safetycondition: the handle of everyBINDER_TYPE_FDobject at an offset inobjectsmust be an open file descriptor that stays open until the parcel drops. Reading such an object duplicates that fd, as 0.11.0 already did, and the parcel closes none of them; the signature and behavior are unchanged. Under the 0.11.0 conditions a buffer naming a closed fd number was allowed, and a read then duplicated whatever fd that number named by then.Parcel::__set_for_rpc(rpc+test-util) returnsResult<()>and refuses, withInvalidOperationand the mode left unchanged, a parcel that holds bytes or was built over a received buffer (CHANGELOG Fixed). Call it on a new parcel before writing to it.- Over kernel binder, a service's
on_dumpwrites to a duplicate of the caller's fd, not to the received fd itself (CHANGELOG Fixed). The duplicate is made before the arguments are read; a process out of file descriptors now answers theDUMP_TRANSACTIONwith that error instead of runningon_dump. Over RPC,on_dumpnow runs as well (CHANGELOG Fixed). ProxyHandle::dumptakesF: Into<OwnedFd>, notF: IntoRawFd. AFile, anOwnedFd, aParcelFileDescriptor, aChildStdinand the other owning std types compile unchanged. A raw fd number, or a type that implements onlyIntoRawFd, has to become anOwnedFdfirst (unsafe { OwnedFd::from_raw_fd(fd) }, with the caller vouching that it ownsfd).IntoRawFdis a safe trait that need not hand over ownership, anddumpcloses the fd it takes, so 0.11.0 would close an fd such an implementation had only lent it.IMemoryHeaphas a requiredheap_fd(&self) -> Option<BorrowedFd<'_>>, andheap_idis now a provided method returning that fd's number (-1forNone). An implementation outside rsbinder no longer compiles until it addsheap_fd: return the heap's fd (Some(self.fd.as_fd())), orNonewhile it has none. An ownheap_idmay stay, but nothing reads it for the reply:BnMemoryHeapanswersHEAP_IDwith a duplicate ofheap_fd(andBadValueforNone). In 0.11.0 it borrowed whatever number the safeheap_idmethod returned as an fd, so an implementation returning0or a closed number let one remoteHEAP_IDrequest send an unrelated fd of the process (stdin, say) to the peer. Callers ofheap_id, and the wire, are unchanged.
RPC wire
- The r34 RPC wire (the default profile) now frames messages as android-12
libbinder does; a 0.12.0 peer and an older one no longer connect. A
client writes the
int32session id-1when it connects, then every message as a bareRpcWireHeaderand its body, with nou32length prefix. An older server reads the-1as a frame length pastMAX_FRAME_LEN, and a 0.12.0 server reads an older client's first length as a session id it does not have; both close the connection. Upgrade both ends together. The new wire was checked against android-12 libbinder on an SDK 31 emulator, with each side as client and as server (example-hello/cpp/run_rpc_r34_interop.sh). Every binder in an r34 parcel, a null one included, is now followed by its stability as android-12 writes it, aCategory(0x0c000001for System,0x00000001for null) whatever the host's SDK; a null binder with a declared level or aCategoryof version 0 is refused withBadType.GET_SESSION_IDon r34 answers android-12'sint32, soRpcSession::get_session_idreturns 4 bytes there (was 32); a session not accepted by anRpcServerhas no id and answersUnknownTransaction. A customRpcTransportthat implements onlysend_frame/recv_framecan no longer carry any session:RpcSession::newas a client fails withRpcError::Protocol, as the android-13+ profile always did; implementsend_raw/recv_raw.RpcSession::newas an acceptor reads the id in its firstserve_blockingread and ends the session on any id but-1. - RPC on the android-13+ wire: a null binder now carries a stability
int32, as libbinder's does (CHANGELOG Fixed). An rsbinder 0.11.0 peer on that wire reads the parcel 4 bytes off after a null binder or a null interface, so update both ends together. Parcels without a null binder are unchanged. - An android-13+ RPC session takes its fd mode from the connection header
only, as AOSP does. There
RpcSession::negotiate_fd_transportsends nothing: it returns the header's mode, and aUnixrequest the header did not agree isInvalidOperation; 0.11.0 sentGET_FD_MODEand could switch the session toUnixafter connecting. Request fd passing before connecting withRpcClientConfig::fd_mode(thefd_modeconnect option of the entry API), and on the server withRpcServer::set_supported_fd_modes. A 0.12.0 server answers an android-13+GET_FD_MODEwithUNKNOWN_TRANSACTION, as libbinder does, so a 0.11.0 client callingnegotiate_fd_transporton such a session getsUnknownTransaction. On the r34 wirenegotiate_fd_transportstill negotiates; against a peer withoutGET_FD_MODE(AOSP r34 libbinder answersUNKNOWN_TRANSACTION) it now returnsOk(FileDescriptorTransportMode::None)where 0.11.0 returnedErr(UnknownTransaction), so the first fd write fails (FdsNotAllowed) instead of the negotiation. An android-13 (v0) session has no fd mode in its header and staysNonewhatever both ends request. - An android-13+ RPC server refuses a client that requests an fd mode it does
not support, as AOSP does (
RpcServer.cpp"Rejecting connection: FileDescriptorTransportMode is not supported"):UnixwithoutRpcServer::set_supported_fd_modes(&[Unix])(thefd_modesserve option), orTrusty. The server closes the connection after its handshake response, so the client's setup succeeds and its first call fails withDeadObject. 0.11.0 accepted the client and left only the server atNone: the client's fd writes went out overSCM_RIGHTSand the server dropped the descriptors. Opt in on the server, or connect withoutfd_mode.RpcSession::accept_android13plus_fdwithserver_fd_unix == false(andaccept_android13plus) refuses the same way. The r34 wire is unchanged: itsGET_FD_MODEstill agreesNone. RpcSession::accept_android13plusandaccept_android13plus_fdrefuse a connection header whose session id is neither empty nor 32 bytes, as AOSPRpcServer::establishConnectiondoes, before reading the id; the call returnsStatusCode::RpcError. 0.11.0 read the id and ignored it, so a client that sent a 16-byte id got a session.RpcServeris unchanged: it refused such a connection in 0.11.0 too, when the id matched no session.
RPC sessions and connections
- An RPC session refuses a connection whose transport differs from its
founding one (fd passing or local peer) with
BadTypeat attach. A manual attach (add_{outgoing,incoming}_connection_with_config) over such a transport is refused before its header goes out, so the session stays up; a hand-assembled session (e.g.RpcServer::serve_connection) can hit it too. - An RPC session ends as a whole when any of its connections fails (AOSP
RpcState::handleRpcError). A send or receive failure, a cut frame or an expired deadline on one connection shuts every connection down: calls in flight on the others returnDeadObject, every death recipient fires, and every local object the peer held is released. 0.11.0 retired the failing connection and let a fan-out session go on. After a session ends, reconnect, fetch the root and register callbacks again —rsbinder::Reconnecting(CHANGELOG Added) does this for one service. A client's incoming connection whose serve thread cannot be spawned ends the session too, since the server already holds it as a callback connection. So does every other failed incoming attach, except one whose header never went out (a refused pre-check, a failed connect or header write) or whose connection closed, a reset included, before any byte of the server's"cci"arrived (a refused attach): those leave the session up. A failed outgoing attach (add_outgoing_connection_with_config) ends the session too, since a libbinder server holds the connection once it has the header, except one whose header never went out (a refused pre-check, amax_versionbelow the session's, a transport unlike the founding connection's, an id other than the session'sget_session_id(), a failed connect or header write). A refused attach ends it as well: libbinderandroid-16.0.0_r3and later end their session when they refuse an attach at thesetMaxThreadscap, and the close that refusal produces cannot be told from one that left the server's session up. Stay withinnegotiate()connections and echoget_session_id(). - An expired reply deadline ends the session (
RpcSession::set_timeout,RpcServer::set_reply_timeout, thetimeout/reply_timeoutoptions). The call still returnsTimedOut; the session no longer survives it or skips the late reply. Set it above the slowest legitimate handler; to abandon one call and keep the session, run it on another thread and stop waiting. A wait for a free connection still fails only that call, withWouldBlock. - The session timeout bounds more than the reply wait:
SO_SNDTIMEO(a send that makes no progress for the period ends the session; a steady slow reader is not cut) on every connection of a bundled socket transport, and on a transport of your own only if it implementsRpcTransport::set_write_timeout(without it a stalled send has no bound), the kernel's keepalive check on TCP and TLS over TCP (RpcTransport::set_livenesshas the values and platform differences), and on a client each connect and handshake step (connect(2)itself fortcp_debugandtlsonly, not forunixorvsock). A send deadline that expires part-way through a frame returnsTimedOut(0.11.0:WouldBlock, which means nothing was sent). - A
tlsclient's connect deadline is shared by every address the host name resolves to. In 0.11.0ClientOptions::handshake_timeoutgave each address its own fullconnect(2)deadline, so a host withNaddresses that drop the SYN heldopenforNtimes it. Nowtimeout(or the deprecatedhandshake_timeoutofRpcClientConfigandClientOptions) bounds the tries together: each gets an even share of the time left but at least 2 s (all of it when less is left), an address refused at once leaves its share to the next, and an address slower to accept than its share is given up for the next even when it would have accepted within the deadline. Atimeoutunder 2 s therefore goes to the first address that does not refuse at once. For a host that resolves to several addresses over a link whose connect takes more than the larger oftimeout / Nand 2 s, raise the deadline or connect to one IP address (withtls_server_namenaming the certificate's host). RpcServer::set_handshake_timeoutbounds the whole admission phase, not each read. In 0.11.0 it was a socket deadline (SO_RCVTIMEO,SO_SNDTIMEO) that every read or write started over, so a client whose handshake bytes kept coming was admitted however long they took. Now the connection is dropped once the deadline has passed since its accept, its TLS handshake included and theset_authorizerhook's run left out: a client on a link slow enough to need longer than the deadline (10 s by default) for its whole handshake is refused. On the r34 wire a connection that opens a new session ends the phase only once its first transaction has been served, so a large first transaction counts too; a connection that joins an existing session ends it with its session-id preamble. Raise the deadline for such links. A client's handshake deadline (timeout, or the deprecatedhandshake_timeoutofRpcClientConfig,RpcUnixClientConfigandClientOptions) bounds each TLS or android-13+ handshake step as a whole in the same way: in 0.11.0handshake_timeoutbounded each read, and now a step that outlasts it fails the setup call withTimedOut.RpcSession::from_preconnected_fdbounds its handshake by a fixed 10 s the same way, where 0.11.0 bounded each read by it. No API changes that 10 s: for a peer that needs longer for the whole handshakefrom_preconnected_fdreturnsTimedOut, and a service-manager lookup that resolves through such an Accessor finds no service (the reason is logged).- An expired TLS handshake is
RpcError::Timeout, notIo(WouldBlock):TlsTransport::connect,accept,connect_streamandaccept_streamreturnRpcError::Timeout(StatusCode::TimedOut) when the stream's deadline ends the handshake. 0.11.0 returnedRpcError::Ioof kindWouldBlock. - On Linux and Android a send or read deadline counts the peer taking this
end's bytes as progress (
SIOCOUTQ; thetransportmodule doc "A slow peer"). A consumer that reads a large reply slower thanset_idle_timeout's or the session timeout's period, but keeps reading, is no longer cut mid-send, nor while the tail of a reply whose send returned is still on its way, and a reply wait goes on while the peer is still taking the request. Apple keeps the 0.11.0 rule: a send's progress is a send that accepted bytes, and a read deadline counts only the peer's bytes. So does a socket that refusesSIOCOUTQ(Android SELinux grantsioctlon another domain's socket only where policy says so, and a kernel whose vsock does not report its queue refuses it on a vsock socket); it is asked once, not on every wait. - TCP connections, TLS over TCP included, have keepalive on by default, at the system's intervals without a session timeout (hours), so a session whose peer host vanished ends and fires its death recipients. No wire byte changes.
RpcServer::set_idle_timeoutjudges the session, not the connection: a session ends once no byte has crossed any of its connections and no call has been open for the period, so a fan-out client with one quiet connection is not evicted, and the eviction can come up to one period later than that; theset_idle_timeoutrustdoc states the rule.- A kernel
ETIMEDOUTon an RPC connection is a dead connection, not a deadline of this end's (keepalive orTCP_USER_TIMEOUTgave up): the session ends, and a serve loop reportsNotLocal/Lostinstead of an idle eviction. - RPC
link_to_death(anddeath_signal) is refused withInvalidOperationon a session that would not notice its connection dropping (a client with no incoming connections and no serve loop). Open incoming connections (incoming_connections(n)), or call the newRpcSession::spawn_servebefore linking; the server side is unaffected. - A
onewaycall made inside an RPC handler needs a connection this end opened (CHANGELOG Fixed); without one it isWouldBlock, so a server's oneway callback needs the client's incoming connections. Twoway is unchanged. RpcServer::setup_unix_serverremoves only a stale socket: a regular file is refused withAlreadyExists, a listening socket withEADDRINUSE. Drop removes the socket file only while it is still the one bound. On Apple platforms it removes no socket at all and refuses every existing one withEADDRINUSE, as AOSPsetupUnixDomainServerdoes, so delete a crashed server's socket before restarting: XNU refuses a connect to a live listener whose backlog is full with the sameECONNREFUSEDa stale socket gives, so probing could remove a running server's path. 0.11.0 removed whatever was at the path on every platform.- An RPC
Parcelis sent once (CHANGELOG Fixed): build a new request per call. Sending the same parcel again, or a reply or incoming parcel as a request, returnsInvalidOperation; a parcel built on another session's proxy returnsBadType(AOSPRpcState::validateParcel), as do a local binder written into a sent parcel and aParcelableHolderread on one session written into a parcel of another. In 0.11.0 a scalar-only request could be resent.RpcProxy::transactrefuses a parcel built for no session (Parcel::new()) withBadType; build it withbuild_request. A handler that replaces its reply with such a parcel, or with one built on another session, sends the callerBadType; with a received or already-sent parcel,InvalidOperation. 0.11.0 sent their bytes. - An unresolved
ParcelableHoldercannot be relayed over an r34 or android-13 v0/v1 RPC session; relay requires wire v2 (android-16). Writing a holder that was read but not decoded withget_parcelableinto a parcel of such a session isBadType, even when the payload holds only data: those wires do not record where a binder sits, so the copy could neither take the references its binders need nor prove there are none. Decode the payload first and write the typed value. On v2 the relay carries binders and fds. In 0.11.0 the bytes were copied as they were, and relaying a holder that held one of the peer's own references freed the node early. - A session parcel takes a holder's bytes only from its own session (AOSP
Parcel::appendFrom): a holder read from a kernel parcel, decoded withfrom_bytesor read on another session, written undecoded into an RPC session parcel, isBadType. It was copied as is. - An android-13+ RPC client setup refuses a session id, and an attach
refuses one other than its session's own, with
BadValue. The setup refuses before any handshake a non-empty id passed tosetup_unix_client_android13plus_with_id,setup_unix_client_android13plus_with_config(RpcUnixClientConfig::session_id),connect_android13plus_fd_with_idorClientOptions::session_id, all now deprecated, and likewise the newsetup_client_android13plus_with_configwithRpcClientConfig::session_id. In 0.11.0 they built a second clientRpcSessionon the server session another one founded; the two kept separate oneway numbering, binder addresses and lifetimes, so the server dropped one side's oneway calls as stale, could dispatch a call for one side's binder to the other side's binder at the same address, and ended the shared session when either side closed. AOSP has no public entry for this either (RpcSession::setupClientis private). To add a connection to a session, calladd_outgoing_connection_with_config(oradd_incoming_connection_with_config) on that session withRpcClientConfig::session_id(&session.get_session_id()?); attaching from another process is not supported, as in AOSP. The wire is unchanged: a server still admits an attach that echoes its id. A manual attach (add_outgoing_connection_with_config,add_incoming_connection_with_configand the deprecatedadd_{outgoing,incoming}_connection_android13plus*forms) likewise refuses, withBadValuebefore connecting, every id other than its own session's server-minted one (get_session_id(), which the session now keeps; an attach on a session that never called it makes that round trip itself). 0.11.0 let oneRpcSessionattach a connection to another server session, which split that server session across two client states the same way and pooled connections to two server sessions in one client session. - Several RPC client setup calls are deprecated in favor of
RpcClientConfig(CHANGELOG Deprecated); they still work, except for the session ids now refused (see above), but-D warningsflags them.
rsbinder-aidl
rsbinder-aidlnow rejects.aidlthat AOSP'saidlalso rejects (CHANGELOG Added). An rsbinder-only contract relying on the missing validation now fails, with a diagnostic naming the rule.error::SemanticErroris now#[non_exhaustive]and gainedFixedSizeNonFixedField,VintfStabilityLeakandDirectionNotSpecified; add a_ => …arm.DirectionPrimitiveis replaced byInvalidDirection, which covers every refused direction and adds the argument name and the permitted directions; itstype_kindis the AIDL type (int,String), not"a primitive type".render::ConstMemberandrender::EnumMembergained a trailing deprecation element; passString::new()to keep the previous output. The#[non_exhaustive]render structs' newdeprecatedfield needs no change.rsbinder-aidlparser API:ParcelableDecl::type_paramsandUnionDecl::type_paramsareVec<TypeParam>(name, span, annotations) instead ofVec<String>; readTypeParam::name. NewGeneric::type_args()returns aFoo<A, B>reference's arguments as written.#[deprecated]codegen can break a downstream-D warningsbuild: an existing/** @deprecated … */javadoc now generates#[deprecated]. Drop the tag, or allow the lint at the call site.- A declaration nested in a
@VintfStabilitytype is now VINTF-stable (CHANGELOG Fixed): aParcelableHolderrecords VINTF stability for it, andParcelableHolder::set_parcelablereturnsBadValuefor a payload without VINTF stability. Nothing in the build warns. rsbinder-aidl: a@FixedSizeunion's implicitTagisTag(pub i8)(CHANGELOG Fixed): code that builds or reads it asi32no longer compiles, and aTag[]isbyte[]on the wire. A@FixedSizeunion with more than 128 fields is rejected, because itsbytetag cannot number the 129th; AOSP'saidlrejects it as well.rsbinder-aidl: au32/u64suffix on a hex literal no longer sets its width (CHANGELOG Fixed), as in AOSPParseIntegral:0xFFFFFFFFu64is theint-1, so alongconstant set to it is-1, not4294967295, and0x1FFFFFFFFu32is alonginstead of a parse error. Nothing in the build warns; a constant sent on the wire changes value.rsbinder-aidl:@nullable(heap=true)is no longer ignored. A parcelable or union field marked with it isOption<Box<T>>wherever it is, as AOSP's Rust backend renders it (aidl_to_rust.cpp:305-306); only one on a reference cycle was boxed before, so code building or matching such a field off a cycle asOption<T>stops compiling (wrap the value inBox::new). Such a field no longer counts as an inline edge either, so a bare@nullablefield it cuts off a cycle isOption<T>, notOption<Box<T>>: inparcelable A { @nullable(heap=true) B b; } parcelable B { @nullable A a; },B.ais nowOption<A>.heap=trueon anything but a parcelable or union type (aString, an array, an interface) and any@nullableparameter butheapare refused, with AOSP's messages. The wire is unchanged. Method arguments and returns keep their types.rsbinder-aidl: a union whose first field is an enum without an initializer defaults to the enum'sDefault(backing value0), not to its first enumerator (CHANGELOG Fixed). Forenum E { A = 5, B = 6 }andunion U { E e; … },U::default()isU::E(E::default()), which isE(0), notU::E(E::A); a default-constructed union sent as is carries0on the wire. An enum whose first enumerator is0is unaffected. Nothing in the build warns.rsbinder-aidl/rsbinder-macros: fixed-size array arguments and returns have AOSP's element types (CHANGELOG Fixed). A@nullable T[N]inargument or return value whose element is not a primitive or enum now wraps each element:Option<&[Option<T>; N]>andOption<[Option<T>; N]>, notOption<&[T; N]>andOption<[T; N]>. Aninout T[N]of a binder, interface orParcelFileDescriptoris&mut [T; N], not&mut [Option<T>; N]. Service implementations and callers of such methods stop compiling until updated, and#[rsbinder::interface]refuses the old spellings and names the new one. The bytes on the wire do not change.rsbinder-aidl: a union variant is its field's name with only the first letter uppercased (CHANGELOG Fixed), as AOSP names it:nullable_ifaceisNullable_iface(wasNullableIface) andURLisURL(wasUrl). A single lowercase word (ns→Ns) or a lowerCamel name (myField→MyField) keeps the variant it had. Code naming any other variant stops compiling. The wire carries the tag number only, so it does not change.rsbinder-aidl: one statement may hold at most 12<that are followed by a name and not closed by a>(CHANGELOG Fixed); 0.11.0 allowed 256. A comparison counts, so a constant such asA < B || C < D || …with 13 or more such comparisons is now rejected; split it across several constants (each;resets the count).rsbinder-aidlchecks argument directions as AOSPaidldoes (GetArgumentAspect,AidlArgument::CheckValid); 0.11.0 generated code for all of the following. Refused:outorinouton anIBinder, an interface or an enum (inonly,@nullableor not) and on a@JavaOnlyImmutableparcelable or union;out ParcelFileDescriptor(inorinoutonly); and an omitted direction on a type that takes more thanin— any array (fixed-size too, whatever the element),List, a parcelable or union (generic too) andParcelFileDescriptor. Writeinwhere the direction was omitted; the generated Rust does not change. For anoutbinder, interface or enum, return it, or use an array (out IBinder[]is&mut Vec<Option<SIBinder>>, and an element leftNonegoes back as a null binder); for anoutfd, useinout. Anoutorinoutprimitive orStringstays refused, now asSemanticError::InvalidDirection.#[rsbinder::interface]and.aidlrefuse AOSP's reserved method signatures —asBinder(),getInterfaceHash()andgetInterfaceVersion()with no argument, andgetTransactionName(int)— as AOSPaidldoes (AidlInterface::CheckValid). Rename such a method; its transaction code, set by declaration order, does not change.rsbinder-aidl: an enum reference in a constant expression takes the type of the enumerator's value, not the enum's@Backingwidth, as AOSPAidlConstantReference::evaluatecopies the referenced value's type (@Backingonly bounds the value,AidlEnumerator::CheckValid). 0.11.0 widened to@Backing, so in along-backed enumBIT = 1thenBIT << 40, and an implicit member after2147483647, folded; both are nowintoverflows. Write the literal at the width you need (BIT = 1L). A unary operator applies at that type too: withA = true,-E.Aistrueand~E.Ais an error; withA = 128u8,-E.Aoverflowsbyte.rsbinder-aidlbinds a dotted name as AOSPResolveNamedoes, for constant references and type names alike: the first segment is looked up in the enclosing types (innermost first), then the imports, then the document's own top-level types, and the rest of the name must exist inside what it found; a first segment found nowhere makes the name fully qualified. A type of the same package declared in another file without an import (an rsbinder extension; AOSP rejects it) is tried only after that, so it never outranks an import or a fully-qualified name. In a package-less file an import now outranks a package-less type of the same simple name. SoQ.Xlooks forXin the typeQnames and nowhere else. 0.11.0 also tried shorter suffixes of a wrong qualifier (wrong.p.I.Xfoldedp.I.X), kept searching outer scopes after an inner type took the first segment (X.Y.Z.Cbeside a nestedXfolded the top-level one), accepted a qualifier that repeats a type's name (X.X.C,E.E.A, a field of typeX.X), let an unqualifiedXfind a constant inside a nested type also namedX, and could foldP.Xto a nested type's constant instead ofP's. These names are now errors andP.Xfolds toP's constant. Name a shadowed outer type fully qualified (p.X.Y).rsbinder-aidllooks an unqualified constant up in the current type only, as AOSPAidlConstantReference::Resolvedoes. 0.11.0 also searched enclosing interfaces, sointerface I { const int K = 5; enum E { A = K } }built with rsbinder but fails in AOSPaidl("Can't find K in E"). Qualify the reference:A = I.K. This includes a field default of enum type:E e = B;is an error (AOSP "Can't find B in P"); writeE.B.rsbinder-aidland#[rsbinder::interface]reserve the__Rsbname prefix (CHANGELOG Fixed). An.aidlinterface, parcelable, enum or union whose name starts with it, an interface trait so named, and a signature path whose first segment starts with it are now rejected; AOSP'saidlaccepts such names. Rename the type, or reach it in a signature through a path such ascrate::….rsbinder-aidl: an AIDL keyword is a whole word and never a name, as AOSP's lexer (aidl_language_l.ll) tokenizes it. A field, argument or enumerator namedin,out,inout,package,import,const,interface,parcelable,enum,unionoronewayis now a syntax error (int in;used to pass when;or,followed the name); rename it. Text such asinterfaceIFoo {,packagep;oroneway oneway interfaceis rejected too. AOSP'saidlrejects all of these, so an.aidlshared with an Android build is unaffected.
rsbinder-macros
#[rsbinder::interface]and the derives now refuse shapes.aidlcannot express, so moving an interface to.aidlstays a no-op. The diagnostic names the.aidlform to use instead. Refused: directions and nullability AIDL lacks (out String,Option<i32>, a bareoutbinder or fd);inspellings.aidlnever renders (&String,&Vec<T>,&i32, a by-valueStringor binder); array-elementOptions spelled the other way (&mut Vec<Option<Cfg>>,Option<&[String]>) andi8in an array element; scalars AIDL has no type for (u32,u64,usize,u128,char); and other shapes such asBinderResult<T, E>,()or&mut [T]arguments, duplicate names, receiver attributes, an emptydescriptor, and on#[derive(Parcelable)]a bare binder or fd field.#[rsbinder::interface]refuses anoutor#[inout]binder object and anoutParcelFileDescriptor,Optionor not, as AOSPaidldoes (GetArgumentAspect:IBinderand an interface goinonly, a fdinorinout).&mut Option<rsbinder::SIBinder>,&mut Option<Strong<dyn IFoo>>and&mut Option<ParcelFileDescriptor>compiled in 0.11.0 and had no Android.aidlcounterpart. Return the object instead, pass the fd#[inout], or use an array, which takes every direction (out:&mut Vec<Option<_>>,#[inout]:&mut Vec<_>).#[rsbinder::interface]refuses two interfaces in one module whose names share a stem —IFooandFooboth nameBnFoo/BpFoo(AOSPClassNamedrops a leadingIbefore an uppercase letter). This failed with E0659 only onceBnFooorBpFoowas used; it now fails at the second declaration with E0428 on__rsbinder_one_interface_per_stem_Foo. Move one of the interfaces to another module or rename it.
rsbinder-tools
rsb_hubchecks every directory above its configuration, not only the configuration itself (CHANGELOG Changed). A group- or world-writable (sticky or not) or foreign-owned directory anywhere on the path refuses the load: one kept under/tmpno longer loads, nor does~/rsbinder/hub.dwhen~/rsbinderwas made0775under umask002. For an unprivilegedrsb_hub, move it under$XDG_RUNTIME_DIRor$HOME; a rootrsb_hubneeds a root-owned path such as/etc/rsbinder/hub.d. Either waychmod g-w,o-wthe directories on the way.rsbinder_tools::config::check_pathis removed: the check now runs on held descriptors, and a path-based probe would report a different inode than the one read.rsb_hubrefuses configurations 0.11.0 loaded: it exits 1 at start, and a SIGHUP reload keeps the policy in force. A[[service]]name must beinterface/instancewith both halves non-empty, withinaddService's name rule (at most 127 bytes of[A-Za-z0-9._/-]); a bare interface name, which 0.11.0 read as<name>/default, is refused. Asystemdunit must not be empty, anexecprogram must be an absolute path, andconnection.portmust be 1–65535 (ConfigError::Tomlotherwise). Anexecprogram must exist and pass the check the configuration itself gets (CHANGELOG Security) — the program and every directory on the way to it from/owned by root orrsb_hub's uid and not group- or world-writable — so a program under/tmpor under a group-writable directory is refused.--configmust name a regular file or a directory; in a directory, a*.tomlentry that cannot be opened (a dangling symlink) fails the load, where 0.11.0 skipped it, and a file whose name starts with.(an editor lock or backup file) is no longer read.rsbinder_tools::config::ConfigErrorgainedEmptyUnit,RelativeExec,UntrustedExec,ExecIoandBadServiceName; an exhaustivematchneeds the new arms.config::InstanceName::parsereturnsOption<InstanceName>:Nonewithout a/or with an empty half.rsb_hubanswers differently in three cases.getDeclaredInstancesreturnsSecuritywhen the interface has declared instances and the caller mayfindnone of them, as AOSPServiceManager.cppdoes; 0.11.0 returned an empty list, sorsb_service instancesexits 2, not 1.addServicesucceeds once the service is registered even if anonRegistrationcallback fails; 0.11.0 returned that callback's error.rsb_hubexits 1 when its signal, death-notification or client-callback thread cannot start, orsigwaitfails, where 0.11.0 kept serving without that thread.rsbinder_tools::nss::gids_for_uidreturnsOption<BTreeSet<u32>>:Noneis a failed lookup, not an unknown uid (that isSomeof an empty set).nss::GroupCacheandconfig::Enforcerhold a boxed resolver and are no longerUnwindSafe/RefUnwindSafe; wrap them inAssertUnwindSafewherecatch_unwindneeds it.rsb_serviceexits 2, not 1, when the device has no service manager: 0.11.0checkanddumpreported every name as not registered there.rsb_devicerefuses a device name over 255 bytes or naming a binderfs entry (binder-control,features,binder_logs) before it mounts anything; 0.11.0 applied--group/--modeto that entry.
Enable binder for Linux
Most Linux distributions do not have Binder IPC enabled by default, so additional steps are required to use it.
Note: The binder driver was mainlined in Linux kernel 4.17, but binderfs — which rsbinder's
rsb_devicetool uses to create binder devices — landed in kernel 5.0. In practice you need kernel 5.0 or later.
If you are able to build the Linux kernel yourself, you can enable Binder IPC by adding the following kernel configuration options:
CONFIG_ANDROID=y
CONFIG_ANDROID_BINDER_IPC=y
CONFIG_ANDROID_BINDERFS=y
Distribution-Specific Guides
Select your Linux distribution for detailed setup instructions:
- Arch Linux - Uses the
linux-zenkernel (simplest method) - Ubuntu Linux - Custom kernel build or module installation
- RedHat Linux - RHEL, CentOS, and Fedora instructions
Other documents related to Binder IPC
Enable Binder IPC on Arch Linux
Arch Linux provides an easy way to enable Binder IPC support through the linux-zen kernel, which already includes all necessary Binder components.
Install linux-zen Kernel
The linux-zen kernel is the recommended and simplest method to get Binder IPC support on Arch Linux:
# Update system packages
$ sudo pacman -Syu
# Install linux-zen kernel and headers
$ sudo pacman -S linux-zen linux-zen-headers
# Update bootloader configuration
$ sudo grub-mkconfig -o /boot/grub/grub.cfg
# Reboot to use the new kernel
$ sudo reboot
After reboot, select the zen kernel from the GRUB menu or set it as default.
Verification
After installing and booting into the zen kernel, verify Binder support is available:
# Check current kernel
$ uname -r
# Should show something like "6.x.x-zen1-1-zen"
Install rsbinder-tools
Install the rsbinder development tools:
# Install Rust (if not already installed)
$ sudo pacman -S rustup
$ rustup default stable
# Install rsbinder-tools from crates.io
$ cargo install rsbinder-tools
Create and Test Binder Device
Create a binder device and test the setup:
# Create binder device (binderfs devices are not persistent —
# re-run this after each reboot)
# Create the `binder` group and put yourself in it (log out and back in,
# or use `newgrp binder`, for the membership to take effect)
$ sudo groupadd -f binder
$ sudo usermod -aG binder "$USER"
# Create the device, owned by that group. The node's mode is the only gate
# on who may speak binder at all, so it defaults to 0600 (root only).
$ sudo rsb_device binder --group binder --mode 0660
# Verify device creation
$ ls -la /dev/binderfs/binder
# Test with a simple example
$ git clone https://github.com/hiking90/rsbinder.git
$ cd rsbinder
# Start service manager in one terminal. --insecure-allow-all runs with no
# access control; see the Service Manager chapter for writing a policy.
$ rsb_hub --insecure-allow-all
# In another terminal, run the example
$ cargo run --bin hello_service &
$ cargo run --bin hello_client
Persistent Configuration
The linux-zen kernel builds binder directly into the kernel
(CONFIG_ANDROID_BINDER_IPC=y), so there is no module to load on boot —
no modules-load.d or modprobe configuration is needed. The only
non-persistent piece is the binderfs device itself: re-run
sudo rsb_device binder --group binder --mode 0660 after each reboot (see
above). The group itself persists; only the device node does not.
Note:
binder_linuxis the out-of-tree Anbox DKMS module, not part of the zen kernel.modules-load.d/modprobe binder_linuxinstructions found elsewhere apply only to that third-party module setup on kernels without built-in binder support.
Troubleshooting
If you encounter issues:
# Check kernel messages for binder-related errors
$ dmesg | grep -i binder
# Verify zen kernel is running
$ uname -r | grep zen
# Verify binder is built into the kernel config
$ zgrep -E "(ANDROID|BINDER)" /proc/config.gz
References
Enable Binder IPC on Ubuntu Linux
Note: This guide is community-contributed and may require adjustments for your specific system configuration. Please test in a safe environment first.
Ubuntu Linux does not enable Binder IPC by default. Here are methods to enable it:
Method 1: Check Existing Kernel Support
Some Ubuntu kernels already include binder support. Check your current kernel first:
# Check if binder is available in your kernel
$ grep -E "(ANDROID|BINDER)" /boot/config-$(uname -r)
If you see CONFIG_ANDROID_BINDER_IPC=y or =m, binder support is already available. Skip to the Verification section.
Method 2: Build Custom Kernel
If your kernel does not include binder support, you can build a custom kernel:
# Install build dependencies
$ sudo apt install build-essential libncurses-dev bison flex libssl-dev libelf-dev
# Download kernel source (replace version as needed)
$ wget https://cdn.kernel.org/pub/linux/kernel/v6.x/linux-6.12.tar.xz
$ tar -xf linux-6.12.tar.xz
$ cd linux-6.12
# Use current kernel config as base
$ cp /boot/config-$(uname -r) .config
# Configure kernel with binder support
$ make menuconfig
# Navigate to: General setup -> Enable Android support
# Enable:
# CONFIG_ANDROID=y
# CONFIG_ANDROID_BINDER_IPC=y
# CONFIG_ANDROID_BINDERFS=y
# Build and install
$ make -j$(nproc)
$ sudo make modules_install
$ sudo make install
$ sudo update-grub
$ sudo reboot
Verification
After enabling binder support, verify it's working:
# Check if binderfs is supported
$ grep binderfs /proc/filesystems
# Test creating a binder device (requires rsbinder-tools)
$ cargo install rsbinder-tools
# Create the `binder` group and put yourself in it (log out and back in,
# or use `newgrp binder`, for the membership to take effect)
$ sudo groupadd -f binder
$ sudo usermod -aG binder "$USER"
# Create the device, owned by that group. The node's mode is the only gate
# on who may speak binder at all, so it defaults to 0600 (root only).
$ sudo rsb_device binder --group binder --mode 0660
Troubleshooting
Common Issues:
- Module not found: Ensure your kernel was built with binder support enabled
- Permission denied: Make sure you're using sudo for device creation
- Kernel too old: The binder driver was mainlined in kernel 4.17, but binderfs (used by
rsb_device) requires kernel 5.0+
Getting Help:
- Check dmesg for kernel messages:
dmesg | grep -i binder - Verify binder is enabled in the kernel config:
grep -E "(ANDROID|BINDER)" /boot/config-$(uname -r)(note:binder_linuxis the out-of-tree Anbox DKMS module —modprobe binder_linuxdoes not apply to a kernel built with the options above) - Check system logs:
journalctl -f
References
Enable Binder IPC on RedHat Linux (RHEL/CentOS/Fedora)
Note: This guide is community-contributed and may require adjustments for your specific system configuration. Please test in a safe environment first.
RedHat-based distributions (RHEL, CentOS, Fedora) do not include Binder IPC support by default, so enabling it means building a kernel.
Fedora
Fedora provides kernel source packages that can be modified to include binder support:
# Install development tools
$ sudo dnf groupinstall "Development Tools"
$ sudo dnf install fedora-packager fedpkg
$ sudo dnf install kernel-devel kernel-headers
# Install kernel build dependencies
$ sudo dnf builddep kernel
# Get kernel source
$ fedpkg clone -a kernel
$ cd kernel
$ fedpkg switch-branch f$(rpm -E %fedora)
$ fedpkg prep
# Modify kernel config to enable binder
$ cd ~/rpmbuild/BUILD/kernel-*/linux-*
$ make menuconfig
# Enable the following options:
# General setup -> Android support (CONFIG_ANDROID=y)
# CONFIG_ANDROID_BINDER_IPC=y
# CONFIG_ANDROID_BINDERFS=y
# Build the kernel
$ make -j$(nproc)
$ sudo make modules_install
$ sudo make install
# Update bootloader
$ sudo grub2-mkconfig -o /boot/grub2/grub.cfg
$ sudo reboot
RHEL/CentOS
Build a Custom Kernel
For enterprise distributions, building a custom kernel is often the most reliable approach:
# Install EPEL repository (CentOS/RHEL 8+)
$ sudo dnf install epel-release
# Install development tools
$ sudo dnf groupinstall "Development Tools"
$ sudo dnf install rpm-build rpm-devel libtool
# Install kernel build dependencies
$ sudo dnf install kernel-devel kernel-headers
$ sudo dnf install elfutils-libelf-devel openssl-devel
# Download kernel source matching your running kernel.
# Pick the v<major>.x directory that matches your kernel: today this
# is typically v6.x on Fedora/Stream; older RHEL 8/9 hosts may still
# be on v5.x or v4.x.
$ KERNEL_VERSION=$(uname -r | sed 's/\.el.*$//')
$ KERNEL_MAJOR=$(echo $KERNEL_VERSION | cut -d. -f1)
$ wget https://cdn.kernel.org/pub/linux/kernel/v${KERNEL_MAJOR}.x/linux-${KERNEL_VERSION}.tar.xz
$ tar -xf linux-${KERNEL_VERSION}.tar.xz
$ cd linux-${KERNEL_VERSION}
# Use current kernel config as base
$ zcat /proc/config.gz > .config
# or
$ cp /boot/config-$(uname -r) .config
# Modify config to enable binder
$ make menuconfig
# Enable CONFIG_ANDROID=y, CONFIG_ANDROID_BINDER_IPC=y, CONFIG_ANDROID_BINDERFS=y
# Build and install
$ make -j$(nproc)
$ sudo make modules_install
$ sudo make install
# Update GRUB
$ sudo grub2-mkconfig -o /boot/grub2/grub.cfg
$ sudo reboot
CentOS Stream
CentOS Stream may have more recent kernels that could include binder support:
# Check current kernel version
$ uname -r
# Update to latest kernel
$ sudo dnf update kernel
# Check if binder is already available
$ grep -E "(ANDROID|BINDER)" /boot/config-$(uname -r)
After Kernel Build
With the kernel built as described above, binder is compiled directly
into the kernel (CONFIG_ANDROID_BINDER_IPC=y is a built-in option,
not a module), so nothing needs to be loaded with modprobe or
configured in modules-load.d:
# Verify binder is built into the running kernel
$ grep -E "(ANDROID|BINDER)" /boot/config-$(uname -r)
Note:
modprobe binder_linux/modules-load.dinstructions found elsewhere refer tobinder_linux, the out-of-tree Anbox DKMS module for kernels without built-in binder support. They do not apply to a kernel built with the options above.
SELinux Considerations
RedHat systems run SELinux, which can deny access to the binder device even
once the node exists and its mode is right. A denial looks like a failed
open rather than an rsbinder error, so check the audit log before suspecting
anything else:
$ sestatus
$ sudo ausearch -m avc -ts recent | grep -i binder
The fix is a policy module allowing your domain to use the device. Putting the
whole system in permissive mode (setenforce 0) will also make the denial go
away, but it disables SELinux for everything else on the machine — reach for it
only to confirm a diagnosis, never as the resting state.
Verification
Test that binder is working:
# Check if binderfs is available
$ grep binderfs /proc/filesystems
# Install rsbinder-tools and create binder device
$ cargo install rsbinder-tools
# Create the `binder` group and put yourself in it (log out and back in,
# or use `newgrp binder`, for the membership to take effect)
$ sudo groupadd -f binder
$ sudo usermod -aG binder "$USER"
# Create the device, owned by that group. The node's mode is the only gate
# on who may speak binder at all, so it defaults to 0600 (root only).
$ sudo rsb_device binder --group binder --mode 0660
# Verify device creation
$ ls -la /dev/binderfs/binder
Troubleshooting
Common Issues:
- Module compilation fails: Ensure all kernel-devel packages match your running kernel
- SELinux denials: Check
audit.logfor SELinux denials and create appropriate policies - Kernel version mismatch: Ensure kernel source matches your running kernel version
Debugging:
# Check kernel messages
$ dmesg | grep -i binder
# Check system journal
$ journalctl -f | grep -i binder
# Verify kernel config
$ grep -E "(ANDROID|BINDER)" /boot/config-$(uname -r)
References
Android Development
Android already has a complete Binder IPC environment, so rsbinder and rsbinder-aidl use the existing device nodes and service manager directly. Nothing here needs rsb_device or rsb_hub, which exist for Linux.
For building in the Android environment, you need to install the Android NDK and set up a Rust build environment that utilizes the NDK.
See: Android Build Environment Setup
Android Version Compatibility
rsbinder supports multiple Android versions with explicit feature flags for compatibility management. The Binder IPC interface has evolved across Android versions, and rsbinder handles these differences transparently.
Supported Android Versions
- Android 10 (API 29):
android_10feature - Android 11 (API 30):
android_11feature - Android 12 (API 31) / 12L (API 32):
android_12feature - Android 13 (API 33):
android_13feature - Android 14 (API 34):
android_14feature - Android 15 (API 35):
android_14andandroid_15— see below - Android 16 (API 36):
android_16feature - Android 17 (API 37): served by the
android_16feature
Note: Android 12L (API 32) uses the same Binder protocol as Android 12, so both are covered by the
android_12feature flag, and Android 17 (API 37) uses the same protocol as Android 16, soandroid_16orandroid_16_pluscovers it. No separateandroid_12lorandroid_17feature is needed.
Android 15 has two service-manager protocols:
android-15.0.0_r6insertedgetService2at index 1 ofIServiceManagerand shifted every transaction code after it by one (addService2 → 3,registerClientCallback11 → 12,tryUnregisterService12 → 13). The SDK version stayed 35, so the build alone does not say which numbering a device speaks. The initial release (r1–r5) is served byandroid_14;r6and later, QPR builds included, byandroid_15. Enable both —android_14_plusand everything wider does — and rsbinder probes the running service manager once at startup to pick the right one. With only one of the two enabled, a device that speaks the other is refused with an error naming the missing feature, rather than addressed with the wrong transaction codes.
Android 10 caveat: Android 10 uses the legacy C
IServiceManager(the AIDL-based interface only landed in Android 11). Theandroid_10dispatch supportstry_get_service,check_service,add_service, andlist_services. APIs introduced later —is_declared,register_for_notifications,unregister_for_notifications, andget_service_debug_info— returnfalseorStatusCode::UnknownTransactionso callers can detect the gap rather than silently misbehave.
Feature Flag Configuration
In your Cargo.toml, specify the Android versions you want to support:
[dependencies]
rsbinder = { version = "0.12", features = ["android_14_plus"] }
Available feature combinations:
android_10_plus: Supports Android 10 through 17android_11_plus: Supports Android 11 through 17android_12_plus: Supports Android 12 through 17android_13_plus: Supports Android 13 through 17android_14_plus: Supports Android 14 through 17android_15_plus: Supports Android 15 (r6and later only) through 17android_16_plus: Supports Android 16 and 17
Protocol Compatibility
rsbinder maintains binary compatibility with Android's Binder protocol:
- Transaction Format: Uses identical
binder_transaction_datastructures - Object Types: Supports all Android Binder object types (BINDER, HANDLE, FD)
- Command Protocols: Implements the same ioctl commands (BC_/BR_ protocol)
- Memory Management: Compatible parcel serialization and shared memory handling
- AIDL Compatibility: Generates code compatible with Android's AIDL interfaces
Because the wire format is identical, the language a service is implemented
in is invisible to its callers: a client generated by rsbinder-aidl can call
an existing Android service written in C++ or Java exactly as it would call an
rsbinder one — no JNI or C shim required. Vendor the service's .aidl from
AOSP, build it with rsbinder-aidl, and look the service up with
hub::try_get_interface::<dyn IFoo>("service.name"). rsbinder relies on this itself:
its hub module drives Android's own C++ servicemanager exactly this way. The
same generated stubs also drive the RPC transport, which is
wire-compatible with real libbinder RPC peers in both directions — though AOSP
exposes only a few services that way (VM / accessor-mediated) rather than over
kernel binder.
Version Detection (Optional)
rsbinder uses rsproperties internally to read Android system properties. You can use the same crate for version detection:
#![allow(unused)] fn main() { // Read Android SDK version (returns default value if not available) let sdk_version: u32 = rsproperties::get_or("ro.build.version.sdk", 0); println!("Android SDK version: {}", sdk_version); // Read release version string let version: String = rsproperties::get_or("ro.build.version.release", String::new()); println!("Running on Android {}", version); }
Add rsproperties to your dependencies:
[dependencies]
rsproperties = "0.6"
Using Android's Existing Binder Devices
On Android, the binder device files are already created and managed by the system. Use ProcessState::init() to connect to the appropriate device:
#![allow(unused)] fn main() { // Connect to the default system binder (/dev/binder) ProcessState::init("/dev/binder", rsbinder::DEFAULT_MAX_BINDER_THREADS)?; }
The second argument is the binder thread-pool ceiling, passed to the kernel
as written. DEFAULT_MAX_BINDER_THREADS (15) is what init_default() uses;
pass 0 only when the process must never have the kernel spawn a binder
thread for it — a single-threaded service manager, or a client that only
makes synchronous outbound calls.
Android provides several binder devices for different purposes:
| Device | Service Manager | Description |
|---|---|---|
/dev/binder | servicemanager | Framework services (default) |
/dev/hwbinder | hwservicemanager | HAL services (HIDL) |
/dev/vndbinder | vndservicemanager | Vendor services |
Warning:
hwbinderuses a different protocol (libhwbinder) than standard binder (libbinder). rsbinder has not been tested withhwbinder, so compatibility is not guaranteed.
You do not need to run rsb_hub on Android — the system already provides service managers for each binder device.
Android-Specific Considerations
- SELinux applies as it does to any other process: a denial surfaces as a
failed
openof the device or a refused transaction, not as an rsbinder error. - Shared memory is allocated with
memfd_create; a peer's legacy/dev/ashmemfd is accepted on the way in. See Shared Memory. @VintfStabilityis honored, including the stability check aParcelableHoldercarries on the wire.
JNI Integration
rsbinder is designed for pure Rust-based programs. Integrating Rust Binder services with Java through JNI is not recommended and was not considered in the design. Since JNI only provides a C interface, multiple data conversions occur (Java → C → Rust), which is inefficient. Instead, develop independent Binder services in Rust and communicate with them from Java clients through the standard Binder IPC mechanism.
Android Build Environment Setup
What you need to build and test rsbinder on Android: the SDK for adb and the emulator, the NDK for the native toolchain, and cargo-ndk to drive them from cargo.
Prerequisites
Android SDK Installation
You need to install the Android SDK which includes essential tools like adb (Android Debug Bridge) and other platform tools required for Android development.
Method 1: Android Studio (Recommended)
Download and install Android Studio, which includes the Android SDK:
Method 2: Command Line Tools Only
If you prefer a minimal installation:
- Download Command Line Tools from Android Developer Downloads
- Extract and set up the SDK manager
Required SDK Components
Install the following SDK components using the SDK Manager:
# Install platform-tools (includes adb)
$ sdkmanager "platform-tools"
# Install emulator (for testing)
$ sdkmanager "emulator"
# Install system images for testing (choose your target API levels)
$ sdkmanager "system-images;android-30;google_apis;x86_64"
$ sdkmanager "system-images;android-34;google_apis;x86_64"
Android NDK Installation
The Android NDK (Native Development Kit) is required for building native Rust code for Android.
Method 1: Through Android Studio
- Open Android Studio
- Go to Tools → SDK Manager → SDK Tools
- Check "NDK (Side by side)" and install
Method 2: Command Line Installation
# Install specific NDK version
$ sdkmanager "ndk;26.1.10909125"
# Check for the latest available version:
$ sdkmanager --list | grep ndk
Use r27 or later if you target a device or emulator image with 16 KB memory pages (Android 15 and up). Earlier NDKs link binaries such an image refuses to load. CI builds with r26c, which is fine for the 4 KB images it runs.
Method 3: Direct Download
Download from the NDK Downloads page and extract manually.
Environment Setup
Set up environment variables in your shell profile (.bashrc, .zshrc, etc.):
# Android SDK
export ANDROID_HOME=$HOME/Android/Sdk # Linux
# export ANDROID_HOME=$HOME/Library/Android/sdk # macOS
# Android NDK (replace with your installed version)
export ANDROID_NDK_ROOT=$ANDROID_HOME/ndk/<your-ndk-version>
export NDK_HOME=$ANDROID_NDK_ROOT
# Add tools to PATH
export PATH=$PATH:$ANDROID_HOME/cmdline-tools/latest/bin
export PATH=$PATH:$ANDROID_HOME/platform-tools
export PATH=$PATH:$ANDROID_HOME/emulator
Rust Toolchain Setup
Install cargo-ndk
$ cargo install cargo-ndk --version "^3.0"
Add Android targets
$ rustup target add aarch64-linux-android
$ rustup target add x86_64-linux-android
$ rustup target add armv7-linux-androideabi
$ rustup target add i686-linux-android
Building rsbinder for Android
Basic Build Commands
# Build for ARM64 (most common for modern Android devices)
$ cargo ndk -t aarch64-linux-android --platform 29 build --release
# Build for x86_64 (emulator)
$ cargo ndk -t x86_64-linux-android --platform 29 build --release
# Build all targets
$ cargo ndk -t aarch64-linux-android -t x86_64-linux-android --platform 29 build --release
rsbinder supports API 29 (Android 10) and newer, so link with --platform 29 or higher (up
to your app's minSdkVersion). Without --platform cargo-ndk links for API 21, whose libc lacks
functions rsbinder calls (process_vm_readv), and the link fails with an undefined symbol.
Using envsetup.sh Helper Scripts
The rsbinder project provides a comprehensive envsetup.sh script with helpful functions for Android development:
# Source the environment setup
$ source ./envsetup.sh
Available Functions
ndk_prepare: Sets up the Android device for testing
- Roots the device using
adb root - Creates the remote directory on the device (
/data/rsbinderby default) - Prepares the environment for file synchronization
$ ndk_prepare
ndk_build: Builds the project for Android
- Reads configuration from
REMOTE_ANDROIDfile - Builds both debug binaries and test executables
- Uses
cargo ndkwith the specified target architecture
$ ndk_build
ndk_sync: Synchronizes built binaries to Android device
- Pushes all executable files to the device
- Uses
adb pushto transfer files to the remote directory - Automatically detects executable files in the target directory
$ ndk_sync
The envsetup.sh script also provides functions for remote Linux testing (remote_sync, remote_shell, remote_test) and publishing (publish, publish_dry_run). These are intended for project maintainers and advanced development workflows.
Configuration File: REMOTE_ANDROID
Create a REMOTE_ANDROID file in the project root to configure your Android target.
It must contain exactly three bare lines in this order — the cargo-ndk target,
the Rust target architecture, and the remote directory on the device. The file is
read line by line, so do not add inline comments or blank lines:
arm64-v8a
aarch64
/data/rsbinder
Common target configurations:
# For ARM64 devices (most modern Android phones)
arm64-v8a
aarch64
/data/rsbinder
# For x86_64 emulator
x86_64
x86_64
/data/rsbinder
Testing on Android
Device Setup
- Enable Developer Options on your Android device
- Enable USB Debugging
- Connect device via USB and authorize debugging
Running Tests
# Complete build and test workflow
$ source ./envsetup.sh
$ ndk_prepare # Set up device
$ ndk_build # Build binaries and tests
$ ndk_sync # Push to device
# Test executables will be available in /data/rsbinder/
Emulator Testing
# Create and start an emulator
$ avdmanager create avd -n test_device -k "system-images;android-34;google_apis;x86_64"
$ emulator -avd test_device
# Build for emulator target
$ cargo ndk -t x86_64-linux-android --platform 29 build --release
Troubleshooting
Common Issues
"adb not found": Ensure platform-tools is installed and in PATH
"cargo-ndk not found": Install with cargo install cargo-ndk
"No targets specified": Add Android targets with rustup target add
"Permission denied on device": Run adb root or check device permissions
Verification Commands
# Check SDK installation
$ sdkmanager --list_installed
# Check connected devices
$ adb devices
# Check available targets
$ rustup target list | grep android
# Test cargo-ndk installation
$ cargo ndk --version