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-tools: CLI tools, including a Binder Service Manager (rsb_hub) for Linux.tests: Port of Android's binder test cases for client/server testing.example-hello: Example service/client implementation using rsbinder.
Key Features of Binder IPC
- Object-oriented: Binder IPC provides a clean and intuitive object-oriented API for inter-process communication.
- Efficient: Binder IPC is designed for high performance and low overhead with efficient data serialization.
- Secure: Binder IPC provides strong security features to prevent unauthorized access and tampering.
- Versatile: Binder IPC can be used for a variety of purposes, including remote procedure calls, data sharing, and event notification.
- Cross-platform: Works on Linux and Android (kernel binder), plus macOS (RPC transport only).
- Async/Sync Support: Supports both synchronous and asynchronous programming models with optional tokio runtime integration.
- 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 binder calls — both stacks share the same AIDL-generated code.
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
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::get_interface() to obtain a strongly-typed proxy to the service.
- 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::get_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.
See RPC Transport for the full story.
Getting Started
Welcome to rsbinder! This guide will help you get started with Binder IPC development using Rust.
Learning Path
If you are new to Binder IPC, we recommend following this learning path:
-
Overview and Architecture - Start here to understand Binder IPC fundamentals
- Learn about the core concepts and components
- Understand the relationship between services and clients
- See how AIDL generates Rust code
-
Installation - Set up your development environment
- Install required dependencies
- Set up binder devices and service manager
- Configure your Rust project
-
Hello World - Build your first Binder service
- Create a simple echo service
- Learn AIDL basics
- Understand service registration and client communication
-
AIDL Guide - Dive deeper into AIDL language features:
- Data Types - How AIDL types map to Rust types
- Parcelable - Custom data structures for IPC
- Enum and Union - Enum and union type support
- Annotations - Code generation annotations
-
Service Development - Build production-quality services:
- Service Patterns - Advanced service patterns and best practices
- Async Service - Non-blocking services with tokio
- Callbacks and Interfaces - Bidirectional communication
- ParcelFileDescriptor - File descriptor passing
- Error Handling - Error types and handling strategies
- Service Manager (HUB) - Service registration and discovery
-
RPC Transport - Binder-over-socket:
- The opt-in second stack that runs on Linux, Android, and macOS
- Unix-domain sockets, vsock, or TLS instead of
/dev/binder - Same generated AIDL stubs as the kernel-binder path
-
Platform-specific Setup - Choose your target platform:
- Linux Setup - For Linux development
- Android Development - For Android integration
Platform Requirements
rsbinder ships two parallel stacks — pick by platform and use case:
- Kernel binder (the default in this guide): Linux 5.0+ with
binderfs enabled (the
binderfsfilesystem landed in 5.0), or Android. Talks to the kernel binder driver through/dev/binderfs/binder(Linux) or/dev/binder(Android). - RPC transport (binder-over-socket, opt-in via the
rpcfeature): pure user-space, no kernel binder driver. Runs on Linux, Android, and macOS over Unix-domain sockets, vsock, or TLS. See the RPC Transport chapter.
Windows: not supported on either stack.
macOS: kernel binder is not supported (no kernel driver), but the RPC transport works natively — useful for developing and testing RPC services on a macOS workstation without a Linux VM.
Quick Start Checklist
Before diving into development, ensure you have:
- Rust 1.85+ installed
- Linux kernel with binder support enabled (or an Android device/emulator)
-
Created binder device using
rsb_device(Linux only) -
Service manager (
rsb_hub) running (Linux only) - Basic understanding of AIDL syntax (covered in the Hello World tutorial)
Key Concepts to Understand
- Services: Server-side implementations that provide functionality
- Clients: Applications that consume services through proxies
- AIDL: Interface definition language for describing service contracts
- Service Manager: Central registry for service discovery
- Parcels: Serialization format for data exchange
- Binder Objects: References that enable cross-process communication
Common Development Workflow
- Define your service interface in an
.aidlfile - Use
rsbinder-aidlto generate Rust code - Implement your service logic
- Register the service with the service manager
- Create clients that discover and use your service
Ready to start? Head to the Overview section to learn the fundamentals!
Installation
Prerequisites
Rust Version Requirements
rsbinder requires Rust 1.85 or later. Ensure you have the latest stable Rust toolchain:
$ rustup update stable
$ rustc --version # Should be 1.85+
Enable binder for Linux
Please refer to Enable binder for Linux for detailed instructions on setting it up.
Create binder device file for Linux
After the binder configuration of the Linux kernel is complete, a binder device file must be created.
Method 1: Install from crates.io
Install rsbinder-tools and run to create a binder device:
$ cargo install rsbinder-tools
$ sudo rsb_device binder
Method 2: Build from source
If you prefer to build from source:
$ git clone https://github.com/hiking90/rsbinder.git
$ cd rsbinder
$ cargo build --release
$ sudo target/release/rsb_device binder
The rsb_device tool will:
- Create
/dev/binderfsdirectory if it doesn't exist - Mount binderfs filesystem
- Create the specified binder device
- Set appropriate permissions (0666) for user access
Run a service manager for Linux
If rsbinder-tools is already installed, the rsb_hub executable is also installed. Run it as follows:
$ rsb_hub
Alternatively, if building from source:
$ cargo run --bin rsb_hub
The service manager (rsb_hub) provides:
- Service registration and discovery
- Service lifecycle management
- Priority-based service access
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", 0)?; }
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.8"
async-trait = "0.1"
env_logger = "0.11" # Optional: for logging
[build-dependencies]
rsbinder-aidl = "0.8"
Feature Flags
rsbinder supports various feature flags for different use cases:
[dependencies]
rsbinder = "0.8" # Default: includes tokio async runtime
# or
rsbinder = { version = "0.8", features = ["async"] } # Async trait support without tokio — use your own async runtime
# or
rsbinder = { version = "0.8", default-features = false } # Synchronous only (no async support)
# or — opt in to the binder-over-socket stack (see RPC Transport chapter)
rsbinder = { version = "0.8", features = ["rpc"] }
Available features:
tokio(default): Full tokio async runtime support (includesasyncfeature)async: Async trait support without tokio runtime — use this when integrating with a different async runtimerpc: Enable the RPC transport (binder-over-socket). Addrpc-vsock,rpc-tls, orrpc-tcp-debugto bundle the matching backend. See RPC Transport.android_*: Android version compatibility flags (see Android Development)
Crate Purposes:
- rsbinder: Core library providing Binder IPC functionality, including kernel communication, data serialization/deserialization, thread pool management, and service lifecycle.
- async-trait: Required for async interface implementations generated by rsbinder-aidl.
- rsbinder-aidl: AIDL-to-Rust code generator, used in build.rs for compile-time code generation.
- env_logger: Optional but recommended for debugging and development logging.
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.8"
async-trait = "0.1"
env_logger = "0.11"
[build-dependencies]
rsbinder-aidl = "0.8"
Add rsbinder and async-trait to [dependencies], and add rsbinder-aidl to [build-dependencies].
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
The reason for creating an additional hello folder is to create a namespace for the hello package.
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(); }
This uses rsbinder-aidl to specify the AIDL source file (IHello.aidl) and the generated Rust file name (hello.rs), and then generates the code during the build process.
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. include!(concat!(env!("OUT_DIR"), "/hello.rs")); // Set up to use the APIs provided in the code generated for Client and Service. pub use crate::hello::IHello::*; // Define the name of the service to be registered in the HUB(service manager). pub const SERVICE_NAME: &str = "my.hello"; }
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.
Let's configure the src/bin/hello_service.rs file as follows.
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::status::Result<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(); // Initialize ProcessState with the default binder path and the default max threads. println!("Initializing ProcessState..."); ProcessState::init_default()?; // Start the thread pool. // This is optional. If you don't call this, only one thread will be created to handle the binder transactions. println!("Starting thread pool..."); ProcessState::start_thread_pool(); // Create a binder service. println!("Creating service..."); let service = BnHello::new_binder(IHelloService{}); // Add the service to binder service manager. println!("Adding service to hub..."); hub::add_service(SERVICE_NAME, service.as_binder())?; // Join the thread pool. // This is a blocking call. It will return when the thread pool is terminated. Ok(ProcessState::join_thread_pool()?) }
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::status::Result<()> { 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(); // Initialize ProcessState with the default binder path and the default max threads. ProcessState::init_default()?; println!("list services:"); // This is an example of how to use service manager. 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)?; // Create a Hello proxy from binder service manager. let hello: rsbinder::Strong<dyn IHello> = hub::get_interface(SERVICE_NAME) .unwrap_or_else(|_| panic!("Can't find {SERVICE_NAME}")); let recipient = Arc::new(MyDeathRecipient {}); hello .as_binder() .link_to_death(Arc::downgrade(&(recipient as Arc<dyn DeathRecipient>)))?; // 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, make sure you have the service manager running:
# In terminal 1: Start the service manager
$ rsb_hub
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:
Initializing ProcessState...
Starting thread pool...
Creating service...
Adding service to hub...
hello_client output:
list services:
my.hello
manager
MyServiceCallback: my.hello
Result: Hello World!
The client demonstrates several advanced features:
- Service Discovery: Lists all available services
- Service Callbacks: Registers for service availability notifications
- Death Recipients: Monitors service lifecycle for cleanup
- Type-safe Proxies: Uses strongly-typed interface for service calls
Troubleshooting
If you encounter issues:
- "ProcessState is not initialized!" -
ProcessState::init_default()(orProcessState::init()) must be called inmain()before using any other rsbinder APIs - "environment variable OUT_DIR not defined" -
build.rsmust be placed in the project root directory (next toCargo.toml), not insidesrc/ - "Can't find my.hello" - Ensure the service is running and registered
- Permission errors - Check that binder device has correct permissions (0666)
- Service manager not found - Verify
rsb_hubis running - Build errors - Ensure all dependencies are correctly specified in Cargo.toml
Next Steps
Congratulations! You've successfully created your first Binder service and client. Here are some next steps to explore:
Caller Identity and Access Control
Inside a service method, you can identify the calling process using CallingContext:
#![allow(unused)] fn main() { use rsbinder::thread_state::CallingContext; fn echo(&self, echo: &str) -> rsbinder::status::Result<String> { let caller = CallingContext::default(); let caller_uid = caller.uid; let caller_pid = caller.pid; let caller_sid = caller.sid; // Optional SELinux context // Enforce your own access control policy if caller_uid != expected_uid { return Err(rsbinder::Status::from(rsbinder::StatusCode::PermissionDenied)); } Ok(echo.to_owned()) } }
This is especially useful since rsbinder does not enforce any access control policy by itself — it is up to each service to validate callers.
Error Handling
Services can return service-specific errors to clients using Status::new_service_specific_error:
#![allow(unused)] fn main() { fn echo(&self, echo: &str) -> rsbinder::status::Result<String> { if echo.is_empty() { return Err(rsbinder::Status::new_service_specific_error(-1, None)); } Ok(echo.to_owned()) } }
On the client side, these errors can be inspected through the Status type to distinguish between transport errors and application-level errors.
AIDL Annotations
Generated types from AIDL do not derive Clone by default, because some AIDL types contain non-cloneable fields such as ParcelFileDescriptor (which wraps OwnedFd) or ParcelableHolder (which contains a Mutex).
You can opt-in to Clone (and other traits) for specific types using the @RustDerive annotation in your AIDL file:
@RustDerive(Clone=true, PartialEq=true)
parcelable MyData {
int id;
String name;
}
@RustDerive is supported for parcelable and union types. This follows the same convention as Android's AIDL Rust backend. The annotation will only compile successfully if all fields in the type actually implement the requested traits.
Explore More Features
- AIDL Data Types: Learn how AIDL types map to Rust, including primitives, arrays, strings, and nullable types
- Parcelable: Define custom data structures that can be sent across Binder IPC
- Enum and Union: Use enum and union types in your AIDL interfaces
- Annotations: Control code generation with
@RustDerive,@Backing,@nullable, and more - Service Patterns: Advanced service patterns including
dump(), default implementations, and multi-service processes - Async Service: Use async/await with tokio runtime for non-blocking services
- Callbacks and Interfaces: Implement bidirectional communication and death recipients
- ParcelFileDescriptor: Pass file descriptors across process boundaries
- Error Handling: Service-specific errors, status codes, and exception handling
- Service Manager (HUB): Registration, lookup, notifications, and debug info
- API Reference: See the full API documentation at docs.rs/rsbinder
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
# 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.
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.
How to use this guide
The chapters are roughly ordered by how often you need each topic when building a new service:
- Skim Data Types so you know what AIDL primitives translate to in Rust.
- Read Parcelable when you need to pass structured data.
- Reach for Enum and Union when designing variant payloads or constant sets.
- Consult Annotations as a reference whenever the generated Rust does not look the way you expect.
Once you are comfortable with the AIDL surface, 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.
This chapter covers the supported AIDL data types, how they map to Rust, and common patterns you will encounter when working with rsbinder.
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> |
| IBinder | &SIBinder | ||
| ParcelFileDescriptor | &ParcelFileDescriptor |
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::status::Result<bool> { Ok(token) } fn RepeatByte(&self, token: i8) -> rsbinder::status::Result<i8> { Ok(token) } fn RepeatInt(&self, token: i32) -> rsbinder::status::Result<i32> { Ok(token) } // ... similar for other types } }
Each method returns rsbinder::status::Result<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::status::Result<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::status::Result<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(@nullable in int[] input);
Rust service implementation:
#![allow(unused)] fn main() { fn RepeatNullableIntArray(&self, input: Option<&[i32]>) -> rsbinder::status::Result<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.
Tips
Here are a few practical details to keep in mind when working with AIDL data types in rsbinder:
-
The
bytetype has a subtle difference between single values and arrays. A singlebyteparameter maps toi8(signed), but when used in theReverseBytepattern, array elements useu8(unsigned). This matches Android's Binder behavior where byte arrays are treated as unsigned. -
Rust strings are always UTF-8, so
@utf8InCpphas no special behavior. In Android's C++ backend, this annotation switches betweenString16(UTF-16) andstd::string(UTF-8). Since Rust'sStringtype is inherently UTF-8, bothStringand@utf8InCpp Stringproduce identical code. -
Arrays in AIDL map to slices for input and
Vecfor output. Input arrays use&[T], which is efficient because no allocation is needed on the caller side. Output arrays and return values useVec<T>, giving the service ownership of the returned data. -
Nullable types use
Option. This is idiomatic Rust and avoids the null pointer pitfalls found in C++ and Java Binder implementations. Always check forNoneon the client side when calling methods that return nullable types. -
Direction tags affect performance. An
inoutparameter requires serialization in both directions. If you only need data to flow one way, useinoroutto reduce the amount of data copied over the Binder transaction. -
Return values are always
Result. Every AIDL method in rsbinder returnsrsbinder::status::Result<T>, allowing services to report errors usingStatuscodes. Even void methods returnrsbinder::status::Result<()>. -
charis UTF-16, not UTF-8. The AIDLchartype maps to Rust'su16, representing a single UTF-16 code unit. This is not the same as Rust's nativechartype, which is a Unicode scalar value. Be mindful of this difference when working with character data.
For more information on AIDL syntax and features, refer to the Android AIDL documentation.
Parcelable
Parcelable types are user-defined data structures that can be serialized and sent across Binder IPC boundaries. They are defined in AIDL .aidl files, and the rsbinder-aidl code generator automatically produces Rust structs from them. Parcelable types are the primary way to pass structured data between a Binder service and its clients.
Unlike primitive types (such as int, String, or boolean), which AIDL handles natively, parcelable types let you group related fields into a single, coherent structure. This is essential for any non-trivial service interface.
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. Because the module shares its name with the struct, you still reach them with the familiar TypeName::CONSTANT path. 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 type path:
#![allow(unused)] fn main() { use Config::Config; let mut cfg = 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.
Service implementation:
#![allow(unused)] fn main() { fn FillOutStructuredParcelable( &self, parcelable: &mut StructuredParcelable, ) -> rsbinder::status::Result<()> { 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 { 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(@nullable in Empty input);
Service implementation:
#![allow(unused)] fn main() { fn RepeatNullableParcelable( &self, input: Option<&Empty>, ) -> rsbinder::status::Result<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. In AOSP-faithful AIDL the recursive field is marked @nullable(heap=true), signalling to the C++ and Java backends that the inner value lives on the heap so the struct has a finite, known size at compile time. rsbinder accepts the same syntax for source compatibility, but the actual Box<T> wrapping is emitted by the code generator whenever a field references the enclosing parcelable's own type — the heap=true parameter is not what triggers it.
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>>. The @nullable part makes it Option, and the self-reference detection adds the Box wrapper that gives the type a known size. Together they enable a linked-list pattern.
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> = result.as_ref(); 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. The code generator inserts that indirection automatically whenever it sees a field whose type matches the enclosing parcelable.
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.
Tips
Here are some practical guidelines when working with parcelable types in rsbinder:
-
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. -
@nullable(heap=true)is required for recursive types. Without it, the compiler will reject the type due to infinite size. Use this annotation on any self-referential field. -
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 are scoped to the parcelable. When you define
const int MAX_VALUE = 100;inside a parcelable, access it in Rust asMyParcelable::MAX_VALUE. This keeps related constants close to the data they describe.
Enum and Union
AIDL supports two powerful type constructs beyond simple interfaces and parcelables: enums and unions. Enums provide named integer constants with type safety, while unions represent a value that can be one of several different types. Both are fully supported by rsbinder's AIDL compiler and map naturally to Rust constructs.
This chapter covers how to define enums and unions in AIDL, how they translate to Rust code, and how to use them in practice.
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{}. - 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(...):
#![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.) 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)); }
Note that AIDL accepts a @deprecated Javadoc tag on a field, but rsbinder's generator does not currently translate it into a Rust #[deprecated] attribute — the field is generated normally and using it produces no compiler warning.
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 and Best Practices
- Specify
@Backingexplicitly for enums. It is optional (the default isbyte), but stating it makes the wire format and the Rust integer type unambiguous to readers. - The union default is always the first field. Order your fields accordingly, placing the most common or natural default first.
- Use
@RustDerive(Clone=true, PartialEq=true)on unions so they can be compared and cloned in Rust. Without this, you cannot use==or.clone()on union values. - Union constants are module-level, not variants. Access them as
Union::S1, not through any variant. - Enum
enum_values()returns all defined constants, which is useful for exhaustive testing or validation loops. - Forward compatibility: Because AIDL enums are backed by integers, a service may receive values not defined in the current enum. Design your code to handle unknown values gracefully.
- Tag enums use lowercase field names (e.g.,
Union::Tag::ns, notUnion::Tag::Ns), matching the original AIDL field names.
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
AIDL annotations modify how the code generator produces Rust code from .aidl files. They control everything from trait derivation and backing types to nullability and interface stability. This chapter covers the annotations relevant to the Rust backend in rsbinder, with examples showing how each annotation affects the generated code.
If you are new to AIDL data types, read the AIDL Data Types chapter first. Annotations build on those type mappings by adding metadata that changes how types are generated, serialized, or constrained.
@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) |
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(@nullable in int[] values);
}
The generated Rust signatures use Option:
#![allow(unused)] fn main() { fn getName(&self) -> rsbinder::status::Result<Option<String>>; fn setValues(&self, values: Option<&[i32]>) -> rsbinder::status::Result<()>; }
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.
In rsbinder, the Box<T> wrapping is driven by self-reference detection in
the code generator, not by the heap=true parameter — the parameter is
accepted for AOSP-faithful AIDL syntax compatibility but does not itself
control the wrapping. Whenever a parcelable field references the enclosing
parcelable's own type, the generator emits Box<...> around it to give the
struct a known size. Keep the heap=true form when writing AIDL that 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 recognizes @VintfStability and stamps the generated type/interface with Stability::Vintf (so it carries the right stability tier on the wire), but it does not statically or dynamically enforce the "fields must also be VINTF-stable" rule — there is no field-tree validation and no BadValue raised for embedding a non-VINTF type. Treat the constraint as a contract you are responsible for upholding, not one the compiler checks for you.
@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;
}
Intended constraints
In upstream Android, a fixed-size parcelable may only contain:
- Primitive types (
boolean,byte,char,int,long,float,double) - Other
@FixedSizeparcelables - Enums with a
@Backingannotation
and may not contain String/@utf8InCpp String, arrays (T[]), ParcelFileDescriptor, IBinder, or any other variable-length type.
rsbinder note: the generator accepts
@FixedSizebut currently treats it as a no-op — it neither validates these constraints nor changes the generated layout or wire format. The constraints above are the contract you should follow; rsbinder does not check them for you.
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
The following table provides a quick reference for all annotations covered in this chapter.
| 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 | AOSP-faithful syntax for recursive fields; rsbinder boxes self-referential fields automatically (parameter accepted but not required) |
@utf8InCpp | String | No effect in Rust (strings are always UTF-8) |
@Descriptor | interface | Overrides the wire descriptor string |
@VintfStability | parcelable, interface | Enforces VINTF stability rules |
@FixedSize | parcelable | Restricts fields to fixed-size types, enables Copy |
@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.
Service Development
Once your AIDL interface compiles, the next step is building 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: how to structure a service, how to go async, how to model bidirectional communication, how to ship file descriptors, how to surface errors, and how to register and discover services through the HUB.
If you have not yet built a working service, start with Hello, World! and then return here for the deeper material.
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.
- 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.
Suggested reading order
The chapters are designed to be read in roughly the order above — each builds on the previous one. If you only need to ship a working service quickly, Service Patterns plus Service Manager (HUB) is enough to get started; the other chapters fill in capabilities as you need them.
Service Patterns
This chapter covers the common patterns for implementing Binder services in rsbinder. Whether you are building a simple single-method service or a complex multi-service process, the patterns described here will help you structure your code effectively.
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::status::Result<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 additional threads for handling concurrent Binder transactions. // Optional but recommended for services that handle multiple clients. 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. hub::add_service("com.example.myservice", service.as_binder())?; // 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 additional threads so the process can handle multiple concurrent transactions. Without this call, only one thread handles all incoming requests.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.as_binder())?; // Register a versioned interface service. let versioned_service = BnFooInterface::new_binder(FooInterface); hub::add_service(versioned_service_name, versioned_service.as_binder())?; // Register a nested service. let nested_service = INestedService::BnNestedService::new_binder(NestedService); hub::add_service(nested_service_name, nested_service.as_binder())?; // 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.as_binder())?; // 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}").unwrap(); } Ok(()) } } }
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 (mut read_file, write_file) = build_pipe(); let args = vec!["dump".to_owned(), "MyService".to_owned()]; service.as_binder().as_proxy().unwrap().dump(write_file, &args)?; let mut buf = String::new(); read_file.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 service = get_service(); assert_eq!(service.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::get_interface returns a Strong<dyn IMyService> on success. let service: rsbinder::Strong<dyn IMyService::IMyService> = hub::get_interface("com.example.myservice")?; // Call service methods through the proxy. let result = service.echo("hello")?; println!("Got: {result}"); Ok(()) }
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::status::Result<()> { println!("Service registered: {name}"); Ok(()) } } let callback = hub::BnServiceCallback::new_binder(MyServiceCallback); hub::register_for_notifications(SERVICE_NAME, &callback)?; }
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.
Tips and Best Practices
ProcessState::init_default()must be called before any Binder operations. Failing to do so will result in a panic.start_thread_pool()is optional but recommended. Without it, only a single thread handles all Binder transactions, which can become a bottleneck under load.- Each process needs only one
ProcessState::init_default()call. Multiple calls are safe but unnecessary. join_thread_pool()blocks the calling thread. Place it at the end ofmain()after all setup is complete.dump()is optional but highly useful for debugging. It provides a standardized way to inspect service state from outside the process.- Use
MutexorRwLockfor mutable service state. All AIDL methods receive&self, so interior mutability is required for state changes. - Service names should follow reverse-domain naming. For example,
com.example.myserviceormy.hello. This prevents name collisions when multiple services are registered. - Error handling: Return
rsbinder::Statuserrors from service methods to communicate failures to clients. UseStatus::new_service_specific_error()for application-level errors that clients can inspect programmatically.
Async Service
rsbinder supports async/await with the Tokio runtime, making it
straightforward to build non-blocking Binder services. The tokio feature is enabled by
default in rsbinder, so no extra feature flags are required for most projects. This chapter
explains how to implement async Binder services, how they differ from their synchronous
counterparts, and the patterns you will encounter when working with them.
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 service facade.
The reason is that the facade 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 starts with the kernel-binder setup, then shows the RPC and unified-facade variants — which differ only in the bootstrap and the runtime flavor.
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 | ProcessState::join_thread_pool() | 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.
The trait, proxy, and constructor rows above are identical across transports — only the
"Main loop" and "Runtime" rows are the kernel-binder values shown here. Over RPC the main
loop is rpc::Host::serve() (or serve_background()) and the runtime must be
multi-threaded; see Async Over RPC and the Unified Facade
below.
Setting Up the Tokio Runtime (kernel binder)
An async Binder service must run inside a Tokio runtime. The standard pattern for kernel binder is:
- Initialize the Binder process state and thread pool (same as sync).
- Build a Tokio runtime.
- Inside the runtime, create and register async services.
- Yield to the runtime with
std::future::pending().await.
Here is the full setup, based on
tests/src/bin/test_service_async.rs:
use rsbinder::*; fn rt() -> TokioRuntime<tokio::runtime::Handle> { TokioRuntime(tokio::runtime::Handle::current()) } fn main() -> std::result::Result<(), Box<dyn std::error::Error>> { // Initialize Binder -- same as the sync case. ProcessState::init_default()?; ProcessState::start_thread_pool(); // Build a single-threaded Tokio runtime. let runtime = tokio::runtime::Builder::new_current_thread() .enable_all() .build() .unwrap(); runtime.block_on(async { // Create and register the async service (see next section). let service = BnMyService::new_async_binder( MyAsyncService::default(), rt(), ); hub::add_service("com.example.myservice", service.as_binder()) .expect("Could not register service"); // Yield to the runtime. This keeps the process alive and // drives the Tokio event loop on the current thread. std::future::pending().await }) }
ProcessState::init_default()returnsResult<&'static ProcessState, …>, so amainthat calls it should also returnResult(or call.expect()/.unwrap()for the very first line of a quick demo).
There are several things to note here:
rt()is a small helper that wraps the current Tokio runtime handle in aTokioRuntime. Every call tonew_async_binderrequires aTokioRuntimeso it knows where to spawn async work.new_current_thread()creates a single-threaded Tokio runtime. This is the recommended choice for Binder services because the Binder thread pool already provides its own threads.std::future::pending().awaitis a future that never resolves. It keeps theblock_oncall (and therefore the process) alive indefinitely, which is the async equivalent of the synchronousProcessState::join_thread_pool().
Async Over RPC and the Unified Facade
Because async lives in the generated stubs and the service
facade only moves an SIBinder, the same async service
runs over RPC with no new code. You register the result of new_async_binder through the
generic Registry, and the client converts the looked-up proxy with into_async:
#![allow(unused)] fn main() { use async_trait::async_trait; use rsbinder::service::{rpc, Registry, Broker}; use rsbinder::*; struct IHelloService; impl Interface for IHelloService {} #[async_trait] impl IHelloAsyncService for IHelloService { async fn echo(&self, echo: &str) -> rsbinder::status::Result<String> { Ok(echo.to_owned()) } } /// Registration is written once, generic over the transport — identical to the /// sync facade example in "Cross-Transport Services". Only the binder /// constructor differs (`new_async_binder` instead of `new_binder`). fn register<R: Registry>( reg: &R, rt: TokioRuntime<tokio::runtime::Handle>, ) -> rsbinder::Result<()> { let binder = BnHello::new_async_binder(IHelloService {}, rt).as_binder(); reg.add_service("hello", binder) } }
The server needs a multi-threaded runtime
This is the one substantive difference from the kernel setup. An RPC async server is driven
by a blocking serve worker that calls rt.block_on(handler). That block_on runs on a
thread that is not the runtime's own, so the runtime must be a multi-threaded one — a
current_thread runtime cannot be driven from an outside thread while it is also being kept
alive elsewhere. (Kernel binder gets away with current_thread only because the binder
thread pool, not a serve loop, supplies the worker threads.)
fn main() -> std::result::Result<(), Box<dyn std::error::Error>> { // RPC async servers require a MULTI-THREAD runtime — the blocking serve // worker calls `rt.block_on(handler)` from a non-runtime thread. let runtime = tokio::runtime::Builder::new_multi_thread() .worker_threads(2) .enable_all() .build()?; let handle = runtime.handle().clone(); let host = rpc::Host::unix("/tmp/hello.sock")?; register(&host, TokioRuntime(handle))?; println!("serving over RPC at /tmp/hello.sock"); host.serve()?; // blocks, driving this one socket Ok(()) }
Use rpc::Host::serve_background() instead of serve()
if you want the socket driven on a background thread while main does other work.
The client converts the looked-up proxy
The client side is the ordinary facade lookup followed by into_async:
#![allow(unused)] fn main() { let runtime = tokio::runtime::Builder::new_multi_thread().enable_all().build()?; let broker = rpc::Broker::unix("/tmp/hello.sock")?; let hello = broker.get_interface::<dyn IHello>("hello")?; // sync Strong<dyn IHello> runtime.block_on(async { let hello = hello.into_async::<Tokio>(); println!("{}", hello.echo("hi").await?); Ok::<_, Box<dyn std::error::Error>>(()) })?; }
The blocking bootstrap —
Broker::unix,get_interface— is done before entering the runtime; only the transacts themselves are.awaited. The async-over-RPC bridge shown here (new_async_binder+into_async::<Tokio>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 rather than therpc::Host/Brokerfacade, but the facade is a thin wrapper over exactly that path.
Switching transports
To run the exact same async service over kernel binder instead, swap only the host
construction and the runtime flavor — the register helper and the service impl are
unchanged:
#![allow(unused)] fn main() { let host = kernel::Host::new()?; // instead of rpc::Host::unix(..) register(&host, rt())?; host.serve()?; // 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 facade 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::status::Result<String> { Ok(input.to_owned()) } fn RepeatInt(&self, token: i32) -> rsbinder::status::Result<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::status::Result<String> { // You can use .await on async operations here. Ok(input.to_owned()) } async fn RepeatInt(&self, token: i32) -> rsbinder::status::Result<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::status::Result<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.
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::status::Result<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.as_binder()) .expect("Could not register service"); let versioned_service = BnFooInterface::new_async_binder( FooInterface, rt(), ); hub::add_service(versioned_service_name, versioned_service.as_binder()) .expect("Could not register service"); let nested_service = INestedService::BnNestedService::new_async_binder( NestedService, rt(), ); hub::add_service(nested_service_name, nested_service.as_binder()) .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::status::Result<$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::status::Result<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::status::Result<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 and Best Practices
-
Use
#[async_trait]from theasync-traitcrate for all async trait implementations. This is required because Rust does not yet have native async trait support in all contexts that rsbinder needs. -
into_async::<Tokio>()converts a synchronous proxy into an async proxy. Always use this when calling another Binder service from an async context, rather than making blocking calls that could stall the Tokio runtime. -
std::future::pending().awaitis the idiomatic way to keep an async service process alive. Unlike the sync approach whereProcessState::join_thread_pool()blocks the main thread, the async approach yields to Tokio so the runtime can drive spawned tasks. -
The
rt()helper should capture the current runtime handle. Define it as a function that returnsTokioRuntime(tokio::runtime::Handle::current())and call it from within the Tokio runtime context. -
Both sync and async services can coexist in the same process. You can register some services with
new_binderand others withnew_async_binder. They share the same Binder thread pool. -
Prefer
new_current_thread()for the Tokio runtime builder. The Binder thread pool handles multi-threaded transaction dispatching already, so a multi-threaded Tokio runtime is typically unnecessary. -
Avoid blocking the Tokio runtime. If your async service method must perform a CPU-intensive or blocking operation, use
tokio::task::spawn_blockingto move that work off the async executor thread.
Summary
Async services in rsbinder follow the same structure as sync services, with a few additional steps:
- Build a Tokio runtime and run your service setup inside
runtime.block_on(async { ... }). - Define an
rt()helper that wrapstokio::runtime::Handle::current(). - Implement
IMyServiceAsyncServicewith#[async_trait]instead ofIMyService. - Create binders with
BnXxx::new_async_binder(impl, rt()). - Use
into_async::<Tokio>()when calling other Binder services from async code. - Keep the process alive with
std::future::pending().await(kernel binder) orrpc::Host::serve()/serve_background()(RPC).
Because async lives in the generated stubs, the same async service runs over either
transport — register the new_async_binder result through the service
facade's Registry and look it up through Broker. The only
RPC-specific requirement is a multi-threaded runtime, because the blocking serve worker
calls rt.block_on(handler).
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.
This chapter covers how to define callback interfaces in AIDL, implement them in Rust, manage collections of callbacks, pass raw IBinder objects, work with nested interface types, and monitor remote service lifecycle with death recipients.
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::get_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.
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
Death recipients are registered using link_to_death and unregistered using unlink_to_death. Both methods take a Weak<dyn DeathRecipient> reference:
#![allow(unused)] fn main() { let recipient = Arc::new(MyDeathRecipient { write_file: Mutex::new(write_file), }); // Register for death notification service .as_binder() .link_to_death(Arc::downgrade( &(recipient.clone() as Arc<dyn DeathRecipient>), )) .unwrap(); // Unregister when no longer needed service .as_binder() .unlink_to_death(Arc::downgrade( &(recipient.clone() as Arc<dyn DeathRecipient>), )) .unwrap(); }
The cast recipient.clone() as Arc<dyn DeathRecipient> is necessary to convert from the concrete type to the trait object before calling Arc::downgrade. The weak reference ensures that the death recipient does not keep the Binder object alive -- if all strong references are dropped, the Binder object can be cleaned up normally.
Note that death notifications only work for remote Binder objects. Calling link_to_death on a local Binder object (one in the same process) will return an error because there is no remote process to monitor.
Tips
Here are key points to keep in mind when working with callbacks and interfaces in rsbinder:
-
Callbacks are full Binder objects. They cross process boundaries transparently. A callback created in the client process can be invoked by the service process through a standard Binder transaction.
-
Use
BnXxx::new_binder()to create callback objects. TheBn(Binder native) wrapper converts your Rust struct into a Binder node that can be sent through Binder transactions. The correspondingBp(Binder proxy) is used automatically on the receiving side. -
Use
Mutexto protect shared state. Binder method calls can arrive on any thread in the thread pool. Any mutable state in your callback or service struct must be protected byMutex,RwLock, or another synchronization primitive. -
Nested types use fully-qualified Rust paths. A callback
ICallbacknested insideINestedServiceis accessed asINestedService::ICallback::ICallbackfor the trait andINestedService::ICallback::BnCallbackfor the Binder node constructor. -
Death recipients use
Weakreferences. Thelink_to_deathAPI takesWeak<dyn DeathRecipient>to avoid preventing cleanup of the death recipient itself. Keep a strongArcreference alive for as long as you want to receive notifications. -
as_binder()converts typed interfaces to rawSIBinder. This is useful when you need to pass a Binder reference to a method that acceptsIBinder, or when you need to call Binder-level methods likelink_to_deathorping_binder. -
Callback equality works through Binder identity. Two
Strong<dyn T>references are equal if they point to the same Binder object. This allows you to compare callbacks received from different sources to determine if they refer to the same underlying implementation.
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); // From a pipe created with rustix let (reader, writer) = rustix::pipe::pipe().unwrap(); let read_pfd = rsbinder::ParcelFileDescriptor::new(reader); let write_pfd = rsbinder::ParcelFileDescriptor::new(writer); }
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 (mut read_file, write_file) = build_pipe(); let write_pfd = rsbinder::ParcelFileDescriptor::new(write_file); // 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 file_from_pfd(&result_pfd).write_all(b"Hello")?; // Read from the original pipe's read end let mut buf = [0u8; 5]; read_file.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.
The idiomatic helper function looks like this:
#![allow(unused)] fn main() { use rsbinder::ParcelFileDescriptor; fn dup_fd(fd: &ParcelFileDescriptor) -> ParcelFileDescriptor { ParcelFileDescriptor::new(fd.as_ref().try_clone().unwrap()) } }
as_ref() returns a reference to the inner OwnedFd, and try_clone() calls
the underlying OS dup system call.
A service method that repeats a file descriptor back to the caller is then straightforward:
#![allow(unused)] fn main() { fn RepeatParcelFileDescriptor( &self, read: &ParcelFileDescriptor, ) -> rsbinder::status::Result<ParcelFileDescriptor> { Ok(dup_fd(read)) } }
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 dup_fd with iterator combinators:
#![allow(unused)] fn main() { fn ReverseParcelFileDescriptorArray( &self, input: &[ParcelFileDescriptor], repeated: &mut Vec<Option<ParcelFileDescriptor>>, ) -> rsbinder::status::Result<Vec<ParcelFileDescriptor>> { repeated.clear(); repeated.extend(input.iter().map(dup_fd).map(Some)); Ok(input.iter().rev().map(dup_fd).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.
Helper Functions
The test suite defines two small helpers that are useful in application code as well.
build_pipe
Creates a Unix pipe and returns both ends as std::fs::File values:
#![allow(unused)] fn main() { use std::fs::File; use std::os::unix::io::FromRawFd; use rustix::fd::IntoRawFd; fn build_pipe() -> (File, File) { let fds = rustix::pipe::pipe().expect("error creating pipe"); unsafe { ( File::from_raw_fd(fds.0.into_raw_fd()), File::from_raw_fd(fds.1.into_raw_fd()), ) } } }
file_from_pfd
Converts a ParcelFileDescriptor reference into a File suitable for use
with the standard Read and Write traits. The descriptor is cloned first
so the original ParcelFileDescriptor remains valid:
#![allow(unused)] fn main() { use std::fs::File; use rsbinder::ParcelFileDescriptor; fn file_from_pfd(fd: &ParcelFileDescriptor) -> File { fd.as_ref() .try_clone() .expect("failed to clone file descriptor") .into() } }
Tips and Best Practices
-
Descriptors are duplicated during IPC. When a
ParcelFileDescriptoris serialized into aParcel, the kernel duplicates the file descriptor for the receiving process. The sender and receiver each hold independent handles. -
Close order does not matter. Because each side owns an independent duplicate, closing the sender's copy does not affect the receiver, and vice versa.
-
Use
file_from_pfdfor reading and writing.ParcelFileDescriptordoes not implementstd::io::Readorstd::io::Writedirectly. Convert it to aFile(viatry_clone().into()) to use those traits. -
Always duplicate before storing. If your service needs to keep a reference to a received descriptor, clone it with
dup_fd. Returning or forwarding the original reference without duplication can lead to use-after-close errors. -
ParcelFileDescriptoris notClone. Because it wraps anOwnedFd, which owns the underlying file descriptor, the type cannot deriveClone. Usedup_fd(oras_ref().try_clone()) for explicit duplication. -
Error handling.
try_clone()can fail if the process has exhausted its file descriptor limit. In production code, consider propagating the error rather than callingunwrap().
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::status::Result<T>
Every AIDL-generated method returns this type:
#![allow(unused)] fn main() { pub type Result<T> = std::result::Result<T, Status>; }
This is a standard Result whose error variant is a Status.
Note — two
Resultaliases coexist inrsbinder. AIDL methods usersbinder::status::Result<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>re-exported fromrsbinder::error. 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. These are defined in rsbinder::StatusCode (re-exported
from rsbinder::error::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 // 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::status::Result<()> { 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()), )) }
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::status::Result<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::status::Result<()> { 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); }
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..."); } } }
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 (extracts the transport error code) 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 and Best Practices
-
Prefer service-specific errors for application logic. Use
Status::new_service_specific_errorwhen the error is meaningful to the caller (e.g., "item not found", "quota exceeded"). Define your error codes as constants so both the service and client can reference them. -
Use
StatusCodefor infrastructure problems. Return rawStatusCodevalues likePermissionDeniedorBadValuefor errors that relate to the IPC mechanism rather than your application's business logic. -
Always check
exception_code()first. The meaning ofservice_specific_error()andtransaction_error()depends on the exception code. Calling them without checking the exception may return default (zero) values. -
Handle
DeadObjectgracefully. In long-running clients, the remote service may restart. Consider using death notifications (link_to_death) to detect service restarts and re-establish connections. -
StatusimplementsDisplayandstd::error::Error. You can use it with?in functions that returnBox<dyn std::error::Error>or with logging macros for human-readable diagnostics.
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
$ cargo run --bin rsb_hub
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.
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 hub::add_service("com.example.myservice", service.as_binder())?; }
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 permitted: Registering a service with a name that is already in use replaces the previous registration.
#![allow(unused)] fn main() { let service = BnFoo::new_binder(FooImpl {}); // Empty names are rejected assert!(hub::add_service("", service.as_binder()).is_err()); // Valid name assert!(hub::add_service("foo", service.as_binder()).is_ok()); // Maximum length (127 characters) let long_name = "a".repeat(127); assert!(hub::add_service(&long_name, service.as_binder()).is_ok()); // Too long (128 characters) let too_long = "a".repeat(128); assert!(hub::add_service(&too_long, service.as_binder()).is_err()); // Special characters are rejected assert!(hub::add_service("happy$foo$fo", service.as_binder()).is_err()); }
Looking Up Services
rsbinder provides three ways to find a registered service, each suited to a different use case.
Type-Safe Lookup with get_interface
The most common approach is hub::get_interface, which retrieves the service
and casts it to the expected AIDL interface type in one step:
#![allow(unused)] fn main() { let service: rsbinder::Strong<dyn IMyService::IMyService> = hub::get_interface("com.example.myservice")?; // Now call methods directly on the typed proxy let result = service.some_method()?; }
If the service is not registered, get_interface returns an error.
Raw Binder Lookup with get_service
If you need the untyped SIBinder handle (for example, to inspect the
descriptor or pass it to another API), use hub::get_service:
#![allow(unused)] fn main() { let binder: Option<SIBinder> = hub::get_service("com.example.myservice"); if let Some(binder) = binder { println!("Found service with descriptor: {}", binder.descriptor()); } }
Returns None if the service is not registered.
Non-Blocking Check with check_service
hub::check_service behaves like get_service but is intended as a
non-blocking availability check:
#![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"); } }
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 |
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::status::Result<()> { 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.
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.
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 | No SELinux enforcement | Full SELinux MAC policy enforcement |
| VINTF manifests | Not supported (is_declared is false) | Supported and enforced |
| 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 |
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+ |
|---|---|---|---|
get_service / check_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, with the caveat that is_declared is always false on
Linux (no VINTF manifest).
Using the ServiceManager Object Directly
The convenience functions (hub::add_service, hub::get_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>` because // initialization can fail (e.g., binder device unavailable, unsupported SDK). // Propagate the error with `?` or handle it with `match`. let sm = hub::default()?; // Use methods on the ServiceManager instance let service = sm.get_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 and Best Practices
-
Initialize ProcessState first. Before calling any
hub::function, you must callProcessState::init_default()(orProcessState::init()with a custom binder path). Failing to do so will panic at runtime. -
Use descriptive service names. Follow a reverse-domain naming convention (e.g.,
com.example.myservice) to avoid name collisions with other services. -
Register for notifications instead of polling. If your client starts before the service it depends on, use
register_for_notificationsrather than repeatedly callingget_servicein a loop. -
Handle registration failures.
add_servicecan fail if the name is invalid or if the caller lacks permission (on Android with SELinux). Always check the result. -
Use
get_interfacefor type safety. Preferhub::get_interfaceoverhub::get_servicewhen you know the expected interface type. It returns a strongly-typed proxy that provides compile-time guarantees. -
Debug with
list_servicesandget_service_debug_info. When troubleshooting, list all registered services and inspect their debug information to verify that services are registered from the expected processes.
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.8", 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::status::Result<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 shutdown(). 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::get_interface(name)for kernel binder, just without a service manager.server.run()runs the accept loop until [RpcServer::shutdown] is called. Use [RpcServer::run_background] to spawn it on a dedicated thread instead.
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, )?; }
Both stacks have 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.
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.8", features = ["rpc", "rpc-vsock"] }
Each backend implements the
RpcTransport
trait — you can implement your own if you need a custom carrier.
TLS client
#![allow(unused)] fn main() { use rsbinder::rpc::{rustls, RpcSession}; use std::sync::Arc; 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(), ); // Third argument must be `Arc<rustls::ClientConfig>` — `setup_tcp_client_tls` // takes the config by shared ownership so it can be cloned per connection. let session = RpcSession::setup_tcp_client_tls( "binder.example.com:9999", "binder.example.com", config, )?; let root = session.get_root()?; }
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.
ParcelFileDescriptor— opt in withRpcSession::negotiate_fd_transportandRpcServer::set_supported_fd_modes. File descriptors ride out-of-band overSCM_RIGHTSon Unix-domain sockets (android-14+ wire required).- Death notifications — link a
DeathRecipienton the proxy as usual. A session whose socket disconnects 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. 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.
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 —
hub::get_service(name)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::get_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),Vsock { cid, port }, orInet(addr)), and the accessor opens the connection on demand:#![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::get_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.
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. Decision record:
plans/2-10-async-rpc-io.md
in the repo.
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.
The rsbinder::service module is an optional, additive layer that makes
that bootstrap transport-agnostic too, so you can write registration and
lookup code once and pick the transport by construction. It is a thin
typed wrapper over the existing APIs — those remain the direct path and are
unchanged.
The traits
#![allow(unused)] fn main() { use rsbinder::service::{Registry, Broker}; }
Registry(server side) —add_service(name, binder).Broker(client side) —lookup(name)plus a genericget_interface::<T>(name)convenience that does theinterface_cast.
Only the genuine kernel∩RPC intersection is on the traits. Kernel-only
powers (list_services, service notifications, lazy services) stay on the
concrete kernel types / the hub module — they are not hidden behind the
trait, nor faked on RPC.
Typed hosts and brokers
Each transport is a distinct type, so the differences stay visible at the call site:
Server (Registry) | Client (Broker) | |
|---|---|---|
| kernel binder | service::kernel::Host | service::kernel::Broker |
RPC (#[cfg(feature = "rpc")]) | service::rpc::Host | service::rpc::Broker |
#![allow(unused)] fn main() { use rsbinder::service::{kernel, rpc, Registry, Broker}; use rsbinder::{SIBinder, Strong}; // Register once — generic over the transport. fn register_all<R: Registry>(reg: &R, svc: SIBinder) -> rsbinder::Result<()> { reg.add_service("hello", svc) } // Look up + cast once — generic over the transport. fn talk<B: Broker>(broker: &B) -> rsbinder::Result<Strong<dyn IHello>> { broker.get_interface("hello") } }
Picking the transport is one line:
#![allow(unused)] fn main() { // Server let host = kernel::Host::new()?; // kernel binder (process-global) // let host = rpc::Host::unix("/tmp/x.sock")?; // RPC over a Unix socket register_all(&host, BnHello::new_binder(MyService).as_binder())?; host.serve()?; // see "serve()" below // Client let broker = kernel::Broker::new()?; // or rpc::Broker::unix("/tmp/x.sock")? let hello = talk(&broker)?; hello.echo("hi")?; }
Why typed pairs, not one Endpoint enum
serve() means different things per transport, the kernel host is a
process-global singleton while an RPC host is a real instance, and the
construction options don't overlap. Keeping them as distinct types makes a
wrong-transport option a compile error rather than a silent no-op, and
keeps the singleton nature of the kernel side honest.
kernel::Host::new()is a process-global, idempotent handle overProcessState.kernel::Host::serve()joins the process-wide binder thread pool (blocks). A second host in the same process reuses the existingProcessState; a conflicting re-init (different driver / max_threads) logs a warning rather than silently dropping the request.rpc::Host::unix(path)binds one socket.rpc::Host::serve()drives that one socket (andserve_background()returns aJoinHandle).
Each host has a builder() for its own options — kernel: driver path,
max_threads, call restriction; RPC: max_threads, max_connections,
authorizer. RPC-only powers (TLS, fd modes, vsock, the connection
counters) stay reachable via host.server().
Security note
Moving a service between transports changes its trust boundary. Before
relying on the facade, read 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 facade makes the transport swap easy; it does not make the security
models the same.
Worked example
example-hello ships bin/unified_service.rs / bin/unified_client.rs:
identical service, registration, and call code with the transport chosen by
one kernel::Host::new() vs rpc::Host::unix(path) line.
cargo run -p example-hello --features rpc --bin unified_service rpc
cargo run -p example-hello --features rpc --bin unified_client rpc
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() { use rsbinder::{Caller, ExceptionCode, Status}; impl IExample for MyService { fn do_thing(&self, arg: i32) -> rsbinder::status::Result<()> { 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 they return 0 / None. If you spawn a new thread
inside a handler, that thread has no calling context (it reads 0) — just
like AOSP.
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::status::Result<()> { 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)), } } }
Caller and PeerIdentity are #[non_exhaustive], so the compiler forces
a catch-all arm — which nudges you toward a fail-closed default.
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. 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::get_service,hub::check_service,hub::get_interface. - AIDL compiler entry points —
rsbinder_aidl::Builder::{new, source, output, version, generate}. - rsb_device binary CLI (binderfs setup).
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.
- 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.- macOS first-class support (plan 2-9 Phase A+B).
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.
rsbinder runs
cargo-semver-checkson every PR for bothrsbinderandrsbinder-aidl(default features and RPC features), so any such change is visible on the PR before merge. - 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.
Enable binder for Linux
Most Linux distributions do not have Binder IPC enabled by default, so additional steps are required to use it.
Note: Binder IPC requires Linux kernel 4.17 or later for native binderfs support.
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)
$ sudo rsb_device binder
# 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
$ rsb_hub
# In another terminal, run the example
$ cargo run --bin hello_service &
$ cargo run --bin hello_client
Persistent Configuration
To automatically load binder modules on boot:
# Create module loading configuration
$ echo "binder_linux" | sudo tee /etc/modules-load.d/binder.conf
# Set module parameters
$ echo "options binder_linux devices=binder,hwbinder,vndbinder" | sudo tee /etc/modprobe.d/binder.conf
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
# Check if modules loaded successfully
$ sudo modprobe -v binder_linux
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
$ sudo rsb_device binder
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: Binder support requires Linux kernel 4.17+ natively
Getting Help:
- Check dmesg for kernel messages:
dmesg | grep -i binder - Verify module loading:
sudo modprobe -v binder_linux - Check system logs:
journalctl -fwhile loading modules
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. Here are methods to enable it:
Fedora
Method 1: Custom Kernel Build (Recommended for 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
Method 2: Third-party Kernel Modules
Some third-party repositories may provide binder modules:
# Enable RPM Fusion repositories
$ sudo dnf install https://mirrors.rpmfusion.org/free/fedora/rpmfusion-free-release-$(rpm -E %fedora).noarch.rpm
# Look for available binder-related packages
$ dnf search binder android-tools
RHEL/CentOS
Method 1: Build 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
Method 2: Using ELRepo (CentOS/RHEL)
ELRepo sometimes provides additional kernel modules:
# Install ELRepo
$ sudo rpm --import https://www.elrepo.org/RPM-GPG-KEY-elrepo.org
$ sudo dnf install https://www.elrepo.org/elrepo-release-8.el8.elrepo.noarch.rpm
# Search for kernel modules
$ dnf --enablerepo=elrepo search kernel-ml
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)
Module Loading (After Kernel Build)
Once you have a kernel with binder support:
# Load binder modules
$ sudo modprobe binder_linux devices="binder,hwbinder,vndbinder"
# Verify modules are loaded
$ lsmod | grep binder
# Create persistent module loading
$ echo "binder_linux" | sudo tee /etc/modules-load.d/binder.conf
# Set module parameters
$ echo "options binder_linux devices=binder,hwbinder,vndbinder" | sudo tee /etc/modprobe.d/binder.conf
SELinux Considerations
RedHat systems use SELinux which may interfere with binder operations:
# Check SELinux status
$ sestatus
# Temporarily disable SELinux for testing
$ sudo setenforce 0
# Create SELinux policy for binder (advanced)
# This requires creating custom SELinux policies for binder devices
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
$ sudo rsb_device binder
# 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
rsbinder provides comprehensive support for Android development alongside Linux. Since Android already has a complete Binder IPC environment, you can use rsbinder, rsbinder-aidl, and the existing Android service manager directly. There's no need to create binder devices or run a separate service manager like on 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 16 (API 36):
android_16feature
Note: Android 12L (API 32) uses the same Binder protocol as Android 12, so both are covered by the
android_12feature flag. Similarly, Android 15 (API 35) uses the same Binder protocol as Android 14, so it is covered by theandroid_14orandroid_14_plusfeature flag. No separateandroid_12lorandroid_15feature is needed.
Android 10 caveat: Android 10 uses the legacy C
IServiceManager(the AIDL-based interface only landed in Android 11). Theandroid_10dispatch supportsget_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.8", features = ["android_14_plus"] }
Available feature combinations:
android_10_plus: Supports Android 10 through 16android_11_plus: Supports Android 11 through 16android_12_plus: Supports Android 12 through 16android_13_plus: Supports Android 13 through 16android_14_plus: Supports Android 14 through 16android_16_plus: Supports Android 16 only
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
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.3"
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", 0)?; }
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
- Service Manager: Uses Android's existing service manager automatically
- Permissions: Respects Android's security model and SELinux policies
- Threading: Integrates with Android's Binder thread pool management
- Memory: Uses Android's shared memory mechanisms (ashmem/memfd)
- Stability: Supports Android's interface stability annotations (@VintfStability)
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
This guide will help you set up a complete Android development environment for building and testing rsbinder applications.
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
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 build --release
# Build for x86_64 (emulator)
$ cargo ndk -t x86_64-linux-android build --release
# Build all targets
$ cargo ndk -t aarch64-linux-android -t x86_64-linux-android build --release
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 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