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:

  1. The traditional kernel binder path through /dev/binder / /dev/binderfs/binder, which most of this guide covers.
  2. An RPC transport (binder-over-socket) — AOSP's RpcServer/RpcSession analogue, pure user-space, no kernel driver. Drives the same generated AIDL stubs over Unix-domain sockets, vsock, or TLS, and runs on Linux, Android, and macOS. See RPC Transport.

Crates

rsbinder offers the following crates:

  • rsbinder: Core library crate for implementing binder service/client functionality.
  • rsbinder-aidl: AIDL-to-Rust code generator for rsbinder.
  • rsbinder-macros: The same generator, reached from a Rust trait instead of an .aidl file. Not a direct dependency — enable rsbinder's macros feature. See Interface Macros.
  • rsbinder-tools: CLI tools — the Binder Service Manager for Linux (rsb_hub), the device setup helper (rsb_device), and the registry inspector (rsb_service).
  • tests: Port of Android's binder test cases for client/server testing.
  • example-hello: Example service/client implementation using rsbinder.

Key Features

  • Object-oriented: a call is a method call on a remote object, with strong and weak references that work across processes — not a message you frame yourself.
  • Wire-compatible with Android: the same protocol as libbinder, so an rsbinder client can call a service written in C++ or Java, and the other way round.
  • Two transport stacks: kernel binder for on-device IPC, and an opt-in RPC transport (Unix sockets, vsock, or TLS) for cross-process, cross-VM, or cross-host calls — both driven by the same AIDL-generated code.
  • Cross-platform: Linux and Android (kernel binder), plus macOS (RPC transport only).
  • Async/Sync: both programming models, with optional tokio integration.

Core Components

  • Parcel: Data serialization and deserialization for IPC transactions
  • Binder Objects: Strong and weak references for cross-process communication
  • AIDL Compiler: Generates Rust code from Android Interface Definition Language files
  • Service Manager: Centralized service discovery and registration (rsb_hub for Linux)
  • Thread Pool: Efficient handling of concurrent IPC transactions
  • Death Notification: Service lifecycle management and cleanup
  • Shared Memory: Payloads too big to copy through a transaction (Shared Memory)

Resources

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

  1. You define your service interface in an AIDL file
  2. The rsbinder-aidl compiler generates Rust code (traits, proxies, and stubs)
  3. Your Service implements the generated trait and registers itself with the HUB (service manager)
  4. A Client queries the HUB to discover the service, then communicates with it through the generated proxy

Description of each component of the diagram

  • AIDL (Android Interface Definition Language)

    • The Android Interface Definition Language (AIDL) is a tool that lets users abstract away IPC. Given an interface (specified in a .aidl file), the rsbinder-aidl compiler constructs Rust bindings so that this interface can be used across processes, regardless of the runtime or bitness.
    • The compiler generates both synchronous and asynchronous Rust code with full type safety.
    • https://source.android.com/docs/core/architecture/aidl
  • Generated Rust Code

    • rsbinder-aidl generates trait definitions, proxy implementations (Bp*), and native service stubs (Bn*).
    • Includes Parcelable implementations for data serialization/deserialization.
    • Supports both sync and async programming models with async-trait integration.
    • Features automatic memory management and error handling.
  • Your Binder Service

    • Implement the generated trait interface to create your service logic.
    • Use BnServiceName::new_binder() to create a binder service instance.
    • Register your service with the HUB using hub::add_service().
    • Join the thread pool to handle incoming client requests.
    • Supports both native services and async services with runtime integration.
  • Binder Client

    • Use hub::wait_for_interface() to obtain a strongly-typed proxy to the service (or hub::check_interface() / hub::try_get_interface() for non-blocking lookup).
    • The generated proxy code handles all IPC marshalling/unmarshalling automatically.
    • Supports death notifications for service lifecycle management.
    • Can register service callbacks for service availability notifications.
    • Full type safety with compile-time interface validation.
  • HUB (Service Manager)

    • In rsbinder, the service manager is referred to as HUB. The hub module in the rsbinder API provides a unified interface (hub::add_service(), hub::wait_for_interface(), etc.) that works on both platforms.
    • On Linux: rsbinder provides rsb_hub, a standalone service manager that you run as a separate process. You must start rsb_hub before registering or discovering services.
    • On Android: The system already provides its own service manager (servicemanager). rsbinder connects to it automatically — no need to run rsb_hub.
    • Handles service registration, discovery, and lifecycle management.
    • Provides APIs for listing services, checking service status, and notifications.

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 through RpcSession::get_root. (Multiple named services on one server are available via RpcServer::add_service.)
  • No kernel driver. ProcessState, rsb_hub, and /dev/binderfs/binder are not involved at all. A pure-RPC process needs none of them.
  • Cross-host / cross-VM by design. Sockets reach further than /dev/binder does — 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() (or init(path, max_threads)) and typically start_thread_pool() / join_thread_pool(). This is global per-process state and lives in the rsbinder core.
  • RPC side is fully independent — RpcServer / RpcSession open their own sockets and run their own accept/worker threads. They do not touch ProcessState, 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 rpc Cargo feature). Mixing the two costs only what each side already costs in isolation — there is no shared singleton between them.
  • Binder objects do not cross between the two. Writing a proxy of one stack into a parcel of the other is refused with InvalidOperation (AOSP does the same). To let a client of one stack reach a service on the other, use an Accessor or a gateway — Cross-Transport Services § Bridging.

See RPC Transport for the full story.

Parcel byte order

The data-parcel wire is little-endian on every host, so a parcel written on one machine reads on another and the bytes match what Android's libbinder sends for the same value. On a little-endian host — every Android target and almost every Linux one — this costs nothing; a big-endian host pays a byte swap.

Not every byte in a parcel is wire:

LayerWhatByte order
Data parcelscalars, arrays, String, the object-free payloadlittle-endian
Kernel command streamthe BC_* / BR_* ioctl bufferhost-native
UAPI structsflat_binder_object, binder_transaction_datahost-native

The lower two go to the kernel driver, which parses them with native loads. So on a big-endian host a kernel parcel carrying a binder is a mixture — little-endian scalars around a native object header — which is correct: the scalars cross to a peer, the object header does not.

The RPC path is verified on big-endian under qemu-user (s390x). The kernel path is structurally correct there but unverified, because no big-endian system ships binderfs to run it on.

Getting Started

Pick a stack

rsbinder ships two, and which one you can use depends on the platform:

  • Kernel binder — Linux 5.0+ with binderfs enabled, or Android. Talks to the kernel driver through /dev/binderfs/binder (Linux) or /dev/binder (Android). This is what most of this guide covers.
  • RPC transport (binder-over-socket, opt-in via the rpc feature) — pure user-space, no kernel driver, no root. Runs on Linux, Android, and macOS, over Unix-domain sockets, vsock, or TLS. See RPC Transport.

macOS has no binder driver, so only the RPC stack works there — enough to develop and test RPC services on a workstation without a Linux VM. Windows is not supported on either stack.

Before you start

  • Rust 1.86 or later
  • For kernel binder: a kernel with binder support, or an Android device/emulator
  • For kernel binder on Linux: a device node created with rsb_device, and rsb_hub running as the service manager

Installation walks through all of these.

The workflow

  1. Define the interface — an .aidl file, or a Rust trait with #[rsbinder::interface].
  2. Generate the Rust from it (rsbinder-aidl in a build.rs; the macro needs no build step).
  3. Implement the service.
  4. Register it and connect to it — rsbinder::serve(uri) and rsbinder::connect(uri).

Hello, World! does all four end to end, and is the fastest way to see where each piece fits.

Installation

Prerequisites

Rust Version Requirements

rsbinder requires Rust 1.86 or later. Ensure you have the latest stable Rust toolchain:

$ rustup update stable
$ rustc --version  # Should be 1.86+

Enable binder for Linux

Please refer to Enable binder for Linux for detailed instructions on setting it up.

Create binder device file for Linux

Once the kernel has binder support, create the device node.

First, create the group that will own the device and put yourself in it (log out and back in, or run newgrp binder, for the membership to take effect):

$ sudo groupadd -f binder
$ sudo usermod -aG binder "$USER"

Then install rsbinder-tools and create the device:

$ cargo install rsbinder-tools
$ sudo rsb_device binder --group binder --mode 0660

Or, building from source:

$ git clone https://github.com/hiking90/rsbinder.git
$ cd rsbinder
$ cargo build --release
$ sudo target/release/rsb_device binder --group binder --mode 0660

rsb_device creates /dev/binderfs if it does not exist, mounts binderfs, creates the named device, and applies --group and --mode.

The mode is not a formality: binder has no in-kernel access control of its own, so the device node is the only thing deciding who can speak binder at all — the same model as /dev/kvm being 0660 root:kvm. It defaults to 0600, root only; grant access deliberately with --group.

Run a service manager for Linux

Installing rsbinder-tools also installs rsb_hub.

rsb_hub denies every request that its policy does not allow, and refuses to start when it cannot load one, so a first run needs one of two things. For a quick local try-out, run with no access control at all:

$ rsb_hub --insecure-allow-all

For anything else, write a policy — see Access control — and point rsb_hub at it (/etc/rsbinder/hub.d is the default):

$ rsb_hub --config /etc/rsbinder/hub.d

Alternatively, if building from source:

$ cargo run --bin rsb_hub -- --insecure-allow-all

rsb_hub is the registry every service registers with and every client looks up through. It also enforces per-name access control, and can declare and start services on demand — see Service Manager.

Using a custom binder device path

By default, rsb_hub uses /dev/binderfs/binder. If you created a binder device with a different name, use the --device (-d) option:

$ rsb_hub --device custom_binder
# or
$ rsb_hub -d custom_binder

In your Rust code, use ProcessState::init() instead of ProcessState::init_default() to specify the matching path:

#![allow(unused)]
fn main() {
// Use a custom binder device path
ProcessState::init(
    "/dev/binderfs/custom_binder",
    rsbinder::DEFAULT_MAX_BINDER_THREADS,
)?;
}

Important: The service manager, service, and client must all use the same binder device path.

Dependencies for rsbinder projects

Add the following configuration to your Cargo.toml file:

[dependencies]
rsbinder = "0.12"
async-trait = "0.1"
env_logger = "0.11"  # Optional: for logging

[build-dependencies]
rsbinder-aidl = "0.12"

Feature flags

rsbinder = "0.12"                                          # default: tokio async
rsbinder = { version = "0.12", default-features = false }   # synchronous only
rsbinder = { version = "0.12", features = ["rpc", "macros"] }
  • tokio (default) — Tokio integration (implies async). It enables only tokio/rt, rt-multi-thread and sync; declare macros, time, net and the rest on your own tokio dependency.
  • async — async-trait support without tokio, for a different runtime.
  • rpc — the RPC transport (binder-over-socket). Add rpc-vsock, rpc-tls, or rpc-tcp-debug for the matching backend. See RPC Transport.
  • macros — #[rsbinder::interface] and the Parcelable / BinderEnum derives: an interface as a Rust trait, with no .aidl file and no build.rs. See Interface Macros. Also the ServiceSpecificError derive (see Error Handling).
  • android_* — Android version compatibility (see Android Development).

What each crate is for

  • rsbinder — the core library: kernel communication, parcel serialization, thread pool, service lifecycle.
  • rsbinder-aidl — the AIDL-to-Rust generator, used from build.rs.
  • async-trait — required by the async interfaces rsbinder-aidl generates.
  • env_logger — optional, but the fastest way to see what rsbinder is doing.

Hello World!

This tutorial will guide you through creating a simple Binder service that echoes a string back to the client, and a client program that uses the service.

Create a new Rust project

Create a library project for the common library used by both the client and service:

$ cargo new --lib hello

Modify Cargo.toml

In the hello project's Cargo.toml, add the following dependencies:

[package]
name = "hello"
version = "0.1.0"
publish = false
edition = "2021"

[dependencies]
rsbinder = "0.12"
async-trait = "0.1"
env_logger = "0.11"

[build-dependencies]
rsbinder-aidl = "0.12"

Create an AIDL File

Create an aidl folder in the project's top directory to manage AIDL files:

$ mkdir -p aidl/hello
$ touch aidl/hello/IHello.aidl

Directory ↔ package mapping. The folder name under aidl/ must match the package declaration at the top of the .aidl file. So aidl/hello/IHello.aidl requires package hello; (below), and the generated Rust path is crate::hello::IHello::IHello. A nested package like package com.example.hello; would need aidl/com/example/hello/IHello.aidl. This mirrors Android's AIDL convention.

Create the aidl/hello/IHello.aidl file with the following contents:

package hello;

// Defining the IHello Interface
interface IHello {
    // Defining the echo() Function.
    // The function takes a single parameter of type String and returns a value of type String.
    String echo(in String hello);
}

For more information on AIDL syntax, refer to the Android AIDL documentation.

Create the build.rs

Create a build.rs file in the project root (next to Cargo.toml) to compile the AIDL file and generate Rust code:

use std::path::PathBuf;

fn main() {
    rsbinder_aidl::Builder::new()
        .source(PathBuf::from("aidl/hello/IHello.aidl"))
        .output(PathBuf::from("hello.rs"))
        .generate()
        .unwrap();
}

Important: The build.rs file must be placed in the project root directory, not inside src/. If placed in the wrong location, you will get a compile error: environment variable OUT_DIR not defined at compile time. Cargo only recognizes build.rs at the project root.

Create a common library for Client and Service

For the Client and Service, create a library that includes the Rust code generated from AIDL.

Create src/lib.rs and add the following content.

#![allow(unused)]
fn main() {
// Include the code hello.rs generated from AIDL and re-export the
// IHello interface items (the trait, BnHello, BpHello, ...) so both
// the client and the service can use them.
rsbinder::include_aidl!("hello", crate::hello::IHello::*);

// Define the name of the service to be registered in the HUB(service manager).
pub const SERVICE_NAME: &str = "my.hello";
}

The rsbinder::include_aidl! macro is the one-call form of the manual include/re-export pair. The above is equivalent to:

#![allow(unused)]
fn main() {
include!(concat!(env!("OUT_DIR"), "/hello.rs"));
pub use crate::hello::IHello::*;
}

Create a service

Create the src/bin/ directory and add the service file. Cargo automatically recognizes .rs files under src/bin/ as binary targets, so no [[bin]] section is needed in Cargo.toml.

Create src/bin/hello_service.rs:

use env_logger::Env;
use rsbinder::*;

use hello::*;

// Define a struct that implements the IHello interface.
struct IHelloService;

// Implement the IHello interface for the IHelloService.
impl Interface for IHelloService {
    // Reimplement the dump method. This is optional.
    fn dump(&self, writer: &mut dyn std::io::Write, _args: &[String]) -> Result<()> {
        writeln!(writer, "Dump IHelloService")?;
        Ok(())
    }
}

// Implement the IHello interface for the IHelloService.
impl IHello for IHelloService {
    // Implement the echo method.
    fn echo(&self, echo: &str) -> rsbinder::BinderResult<String> {
        Ok(echo.to_owned())
    }
}

fn main() -> std::result::Result<(), Box<dyn std::error::Error>> {
    env_logger::Builder::from_env(Env::default().default_filter_or("warn")).init();

    // Create a binder service.
    println!("Creating service...");
    let service = BnHello::new_binder(IHelloService{});

    // `serve(Uri::kernel())` initializes the kernel binder process state,
    // `add` names the service (it takes anything convertible into
    // `SIBinder`), and `run` starts the binder thread pool, registers the
    // service with the service manager and joins the pool — this blocks
    // for the life of the service.
    println!("Serving {SERVICE_NAME}...");
    rsbinder::serve(Uri::kernel())?
        .add(SERVICE_NAME, &service)?
        .run()?;
    Ok(())
}

The same three calls with serve(Endpoint::unix("/tmp/hello.sock")) serve the service over a Unix socket instead of kernel binder — see Cross-Transport Services. The low-level form (ProcessState::init_default(), start_thread_pool(), hub::add_service(), join_thread_pool()) remains available.

Create a client

Create the src/bin/hello_client.rs file and configure it as follows.

#![allow(non_snake_case)]

use env_logger::Env;
use rsbinder::*;
use hello::*;
use hub::{BnServiceCallback, IServiceCallback};
use std::sync::Arc;

struct MyServiceCallback {}

impl Interface for MyServiceCallback {}

impl IServiceCallback for MyServiceCallback {
    fn onRegistration(&self, name: &str, _service: &SIBinder) -> rsbinder::BinderResult<()> {
        println!("MyServiceCallback: {name}");
        Ok(())
    }
}

struct MyDeathRecipient {}

impl DeathRecipient for MyDeathRecipient {
    fn binder_died(&self, _who: &WIBinder) {
        println!("MyDeathRecipient");
    }
}

fn main() -> std::result::Result<(), Box<dyn std::error::Error>> {
    env_logger::Builder::from_env(Env::default().default_filter_or("warn")).init();

    // `connect` on a kernel `Uri` with a service name initializes the kernel
    // binder process state, blocks until the service is registered (the
    // event-driven equivalent of AOSP's `waitForService`), and casts it to
    // the interface.
    let hello: rsbinder::Strong<dyn IHello> =
        rsbinder::connect(Uri::kernel().with_service(SERVICE_NAME))?;

    println!("list services:");
    // Service-manager extras stay on `hub`.
    for name in hub::list_services(hub::DUMP_FLAG_PRIORITY_DEFAULT) {
        println!("{name}");
    }

    let service_callback = BnServiceCallback::new_binder(MyServiceCallback {});
    hub::register_for_notifications(SERVICE_NAME, &service_callback)?;

    // `link_to_death_arc` takes the concrete `Arc<MyDeathRecipient>` directly.
    // Keep `recipient` alive for the link's lifetime (the link holds only a weak ref).
    let recipient = Arc::new(MyDeathRecipient {});
    hello.link_to_death_arc(&recipient)?;

    // Call echo method of Hello proxy.
    let echo = hello.echo("Hello World!")?;

    println!("Result: {echo}");

    Ok(ProcessState::join_thread_pool()?)
}

Project folder and file structure

.
├── Cargo.toml
├── aidl
│   └── hello
│       └── IHello.aidl
├── build.rs
└── src
    ├── bin
    │   ├── hello_client.rs
    │   └── hello_service.rs
    └── lib.rs

Run Hello Service and Client

Before running the service and client, start the service manager. It refuses to start without an access-control policy, so a local try-out passes --insecure-allow-all — see Access control for the real thing:

# In terminal 1: Start the service manager
$ rsb_hub --insecure-allow-all

Now you can run the service and client:

# In terminal 2: Run the service
$ cargo run --bin hello_service
# In terminal 3: Run the client
$ cargo run --bin hello_client

Expected Output

hello_service output:

Creating service...
Serving my.hello...

hello_client output:

list services:
my.hello
manager
MyServiceCallback: my.hello
Result: Hello World!

Along the way the client also listed the registered services, registered for availability notifications, and linked a death recipient.

Troubleshooting

  1. "ProcessState is not initialized!" — rsbinder::serve(Uri::kernel()) / rsbinder::connect(Uri::kernel().with_service(…)) (or the low-level ProcessState::init_default()) must run before any other rsbinder API that touches the binder device.
  2. "environment variable OUT_DIR not defined" — build.rs must be in the project root, next to Cargo.toml, not inside src/.
  3. Client blocks without output — rsbinder::connect on a kernel Uri waits until the service is registered; make sure the service is running.
  4. Permission errors — the binder device node defaults to 0600, root only. Give it a group with rsb_device binder --group <group> --mode 0660 and make sure you are in that group.
  5. Service manager not found — check that rsb_hub is running, and that it did not exit at startup for want of a policy.

Next Steps

Run the Test Suite

The rsbinder project includes a comprehensive test suite ported from Android:

# Terminal 1: Start service manager
$ cargo run --bin rsb_hub -- --insecure-allow-all

# Terminal 2: Start test service
$ cargo run --bin test_service

# Terminal 3: Run tests
$ cargo test -p tests test_client::

Study Real Examples

Check out the rsbinder repository for more complex examples:

  • example-hello: The complete example from this tutorial (note: the package name is example-hello, so import paths use example_hello::* instead of hello::*)
  • tests: Comprehensive test cases showing various IPC scenarios
  • rsbinder-tools: Real-world service manager implementation

AIDL Guide

AIDL (Android Interface Definition Language) is the contract between a Binder service and its clients. You describe the interface — its methods, parameters, and data types — in a .aidl file, and the rsbinder-aidl compiler generates the Rust code on both sides. This guide walks through the parts of AIDL that you will actually use when writing services with rsbinder.

If you have not yet seen rsbinder running end-to-end, read Hello, World! first — it shows where AIDL fits into a complete project.

With rsbinder on both ends, Interface Macros declares the same interface as a Rust trait instead — same compiler, same generated code. Read this guide either way: it describes the type system both paths share.

Chapters

  • Data Types — Primitive, string, array, list, map, and interface types, and how each one maps to Rust on the in / out / inout side. Start here.
  • Parcelable — Defining user-supplied structs that can cross the Binder boundary, including nullable fields and default values.
  • Enum and Union — Backed enums (newtype structs in Rust, for wire-stable forward compatibility) and unions (tagged variants).
  • Annotations — @RustDerive, @nullable, @Backing, @JavaDerive-equivalents, and the other annotations the Rust backend honors.

Read them in that order the first time; Annotations is a reference to come back to when the generated Rust does not look the way you expected.

What the compiler rejects

rsbinder-aidl rejects the .aidl that AOSP's aidl rejects, so a contract written here also builds for the other backends. Each diagnostic names the rule. Besides the @FixedSize and @VintfStability rules in Annotations, it rejects:

  • ParcelableHolder anywhere but a parcelable field: as a method argument or return type, a union member, an array or List element, or @nullable.
  • void anywhere but a method return type.
  • A union with no fields (const members do not count).
  • A duplicate method name in an interface, or a duplicate argument name in a method.
  • A type argument on a type that takes none (String<int>, IBinder<T>, Plain<int> on a non-generic parcelable), or the wrong number of them.
  • A type argument that does not meet its parameter's requirement (MQDescriptor<String, …> where the parameter is @FixedSize T), or that is an array, a List, void or ParcelableHolder.
  • A generic parcelable field whose type is one of the parameters (parcelable Foo<T> { T value; }), and a generic union. See Generic Parcelables.
  • A const whose type is not a primitive, a String, or an array of those.

rsbinder keeps boolean, char and array constants, which AOSP refuses.

rsbinder-aidl also refuses an AIDL name that collides with an item the generated Rust adds next to it, with a diagnostic rather than a rustc duplicate-definition error in the generated file under OUT_DIR. For an interface IFoo, those items are BnFoo, BpFoo, IFooDefault, IFooDefaultRef and a transactions module, with async codegen also BnFooAdapter, IFooAsync and IFooAsyncService, the module-level DEFAULT_IMPL and on_transact, plus VERSION and HASH when a version or hash is set; a union adds Tag, and an enum (a union's Tag included) adds the methods get and enum_values. A nested type, constant, enumerator or union field with one of those names — const int VERSION = 1; in a versioned interface, a nested parcelable Tag in a union, a union field named get — needs another name. AOSP's aidl also refuses a union named Tag and a nested Tag in a union; the other names come from the Rust backend.

Type names starting with __Rsb are refused as well, which AOSP's aidl accepts. The async codegen declares its type parameters and a helper struct (__RsbPool, __RsbService, __RsbRuntime, __RsbAsyncWrapper) in the scope where method signatures are resolved, so a nested type of the same name would resolve to the generated item.

The generated Rust also spells some names without a path, and those names are not refused: a nested type named Vec, Box, Option, String, Default or rsbinder. A nested type becomes a module next to the signatures, fields and bodies of the type that contains it, which write BinderResult<Vec<i32>>, Option<String>, Box<…>, Default::default() and rsbinder::…, so each of those names resolves to the nested type and the generated file under OUT_DIR fails with a rustc error, not an AIDL diagnostic. A fixed-size array field longer than 32 elements is initialized through std::array::from_fn, so a nested type named std breaks that field the same way. AOSP's Rust backend writes Vec and Box with a full path and accepts such a contract; here the nested type needs another name. A generic parcelable's type parameter with one of these names is refused (see Generic Parcelables).

Then move on to Service Development for the runtime patterns that put these types to work.

AIDL Data Types

AIDL (Android Interface Definition Language) defines the interface contract between a Binder service and its clients. When you write an .aidl file, rsbinder-aidl generates Rust code that maps each AIDL type to the corresponding Rust type. Understanding these mappings is essential for implementing services and calling them correctly from client code.

Primitive Types

The following table shows how AIDL primitive types map to Rust types. Input parameters (in) are passed by value or by reference. Only non-scalar types — arrays, parcelables, and nullable references — may be out/inout; the generator rejects a primitive or a String marked out/inout at compile time (it has no fixed slot to write back into).

AIDL TypeRust Type (in)Rust Type (out)Notes
booleanbool— (not allowed)
bytei8— (not allowed)Single values use i8; array Reverse uses u8
charu16— (not allowed)UTF-16 code unit
inti32— (not allowed)
longi64— (not allowed)
floatf32— (not allowed)
doublef64— (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 TOption<&T>&mut Option<T>A nullable String input is Option<&str>
@nullable T[]Option<&[T]>&mut Option<Vec<T>>For a primitive or enum element. A non-primitive element keeps its own Option in every direction and at every arity: Option<&[Option<T>]>, &mut Option<Vec<Option<T>>>, and for T[N] Option<&[Option<T>; N]> (Option<[Option<T>; N]> as a return value), as AOSP's Rust backend renders them
IBinder&SIBinder&mut Option<SIBinder>
ParcelFileDescriptor&ParcelFileDescriptor&mut Option<ParcelFileDescriptor>
An interface&Strong<dyn I>&mut Option<Strong<dyn I>>
A generic parcelable Foo<A, B>&Foo<A, B>&mut Foo<A, B>The arguments follow the type everywhere it appears; see Generic Parcelables

Here is an AIDL interface that exercises the primitive types:

interface IDataService {
    boolean RepeatBoolean(boolean token);
    byte RepeatByte(byte token);
    int RepeatInt(int token);
    long RepeatLong(long token);
    float RepeatFloat(float token);
    double RepeatDouble(double token);
}

The generated Rust trait expects the following signatures. A service implementation simply returns each value back to the caller:

#![allow(unused)]
fn main() {
impl IDataService for MyService {
    fn RepeatBoolean(&self, token: bool) -> rsbinder::BinderResult<bool> {
        Ok(token)
    }
    fn RepeatByte(&self, token: i8) -> rsbinder::BinderResult<i8> {
        Ok(token)
    }
    fn RepeatInt(&self, token: i32) -> rsbinder::BinderResult<i32> {
        Ok(token)
    }
    // ... similar for other types
}
}

Each method returns rsbinder::BinderResult<T>, which allows the service to return either a value or a Status error to the client.

String Types

AIDL String maps to &str for input parameters and String for return values. This follows Rust's standard convention of borrowing for inputs and returning owned data for outputs.

The @utf8InCpp annotation exists in Android AIDL to distinguish between UTF-16 and UTF-8 string encodings in the C++ backend. In Android's C++ Binder, strings are UTF-16 by default and @utf8InCpp switches them to std::string (UTF-8). In rsbinder, this annotation has no effect because Rust strings are always UTF-8. Both String and @utf8InCpp String produce the same Rust type mapping.

A simple service method that echoes a string back to the caller looks like this:

#![allow(unused)]
fn main() {
fn RepeatString(&self, input: &str) -> rsbinder::BinderResult<String> {
    Ok(input.into())
}
}

Note the use of .into() to convert the borrowed &str into an owned String for the return value. You can also use input.to_string() or input.to_owned() -- all three are equivalent here.

Arrays and the Reverse Pattern

A common pattern in AIDL test interfaces is the "Reverse" method. The method receives an input array, copies it into an out parameter called repeated, and returns the reversed array. This exercises both input and output array handling in a single call.

The AIDL definition looks like this:

int[] ReverseInt(in int[] input, out int[] repeated);

In the generated Rust trait, the in parameter becomes a slice reference (&[i32]) and the out parameter becomes a mutable reference to a Vec (&mut Vec<i32>). The return value is also a Vec:

#![allow(unused)]
fn main() {
fn ReverseInt(&self, input: &[i32], repeated: &mut Vec<i32>)
    -> rsbinder::BinderResult<Vec<i32>>
{
    repeated.clear();
    repeated.extend_from_slice(input);
    Ok(input.iter().rev().cloned().collect())
}
}

On the client side, you pass the input array and a mutable Vec to receive the repeated copy. After the call returns, both the repeated vector and the return value are populated:

#![allow(unused)]
fn main() {
let input = vec![1, 2, 3];
let mut repeated = vec![];
let reversed = service.ReverseInt(&input, &mut repeated)?;
assert_eq!(repeated, vec![1, 2, 3]);
assert_eq!(reversed, vec![3, 2, 1]);
}

This pattern applies to all array types, including boolean[], byte[], long[], float[], double[], String[], and arrays of parcelable types. The Reverse pattern is particularly useful in testing because it validates that data survives a round trip through Binder serialization and deserialization in both directions.

Nullable Types

The @nullable annotation indicates that a parameter or return value may be absent. In Rust, this maps naturally to Option<T>.

For input parameters, a nullable array becomes Option<&[T]>. For return values, it becomes Option<Vec<T>>. This allows both the client and service to represent the absence of a value without resorting to sentinel values or empty collections.

AIDL definition:

@nullable int[] RepeatNullableIntArray(in @nullable int[] input);

Rust service implementation:

#![allow(unused)]
fn main() {
fn RepeatNullableIntArray(&self, input: Option<&[i32]>)
    -> rsbinder::BinderResult<Option<Vec<i32>>>
{
    Ok(input.map(<[i32]>::to_vec))
}
}

Client usage:

#![allow(unused)]
fn main() {
let result = service.RepeatNullableIntArray(Some(&[1, 2, 3]));
assert_eq!(result, Ok(Some(vec![1, 2, 3])));

let result = service.RepeatNullableIntArray(None);
assert_eq!(result, Ok(None));
}

When None is passed, the Binder transaction sends a null marker and the service receives None. When a value is present, it is serialized and deserialized normally.

The @nullable annotation can also be applied to String, IBinder, and parcelable types. Without @nullable, these types must always be present -- passing a null value will result in a transaction error.

Parameter Direction: in, out, and inout

AIDL parameters have a direction tag that controls how data flows between client and service. This affects both the wire format (what data is serialized into the Binder transaction) and the generated Rust method signatures.

in (default)

Data flows from the client to the service. This is the default direction and does not need to be specified explicitly (though you can write it for clarity). In Rust, in parameters are passed by value for primitives or by reference for complex types like arrays and strings.

void Process(in int[] data);   // explicit 'in'
void Process(int[] data);      // same as above, 'in' is the default

For primitive types like int and boolean, the in direction simply means the value is copied into the Binder transaction. For complex types like arrays, a slice reference (&[T]) is used so the data is serialized without requiring the caller to give up ownership.

out

Data flows from the service back to the client. The client provides a mutable container and the service fills it with data. In Rust, out parameters are passed as &mut references. The initial contents of the container are not sent to the service -- only the service's written data is transmitted back.

void GetData(out int[] result);

In Rust, this generates a &mut Vec<i32> parameter. The caller should provide an empty or pre-allocated vector; the service is responsible for populating it.

inout

Data flows in both directions. The client sends initial data to the service, the service may modify it, and the modified data is sent back. In Rust, inout parameters are also passed as &mut references, but unlike out parameters, the initial value is serialized and sent to the service.

void Transform(inout int[] data);

Use inout when the service needs to read the existing value and modify it in place. Prefer in or out when data only needs to flow in one direction, as this avoids unnecessary serialization overhead.

Note: Primitive types (boolean, byte, char, int, long, float, double) and String cannot carry an out/inout direction tag — the generator rejects it. As scalar (or, for String, value-typed) parameters they only ever flow in. Direction tags are meaningful only for arrays and parcelable types.

Builtin AOSP Types

Two AOSP packages compile without a vendored .aidl: an import of one of these names resolves to a type the rsbinder crate provides, and the generated code refers to it by that path.

AIDL importRust type
android.os.ParcelFileDescriptorrsbinder::ParcelFileDescriptor
android.hardware.common.NativeHandlersbinder::NativeHandle
android.hardware.common.fmq.MQDescriptorrsbinder::fmq::MQDescriptor<T, F>
android.hardware.common.fmq.GrantorDescriptorrsbinder::fmq::GrantorDescriptor
android.hardware.common.fmq.SynchronizedReadWritersbinder::fmq::SynchronizedReadWrite
android.hardware.common.fmq.UnsynchronizedWritersbinder::fmq::UnsynchronizedWrite
import android.hardware.common.fmq.MQDescriptor;
import android.hardware.common.fmq.SynchronizedReadWrite;

interface IProducer {
    // Generated: fn openQueue(&self) -> BinderResult<rsbinder::fmq::MQDescriptor<i8, rsbinder::fmq::SynchronizedReadWrite>>
    MQDescriptor<byte, SynchronizedReadWrite> openQueue();
}

The hardware/interfaces sources are VINTF-stable and unchanged since Android 11, so the wire format matches a C++ or Java HAL. A copy of one of these files under an include directory takes precedence and is compiled like any other source. rsbinder::fmq also re-exports the rsbinder-fmq crate — the queue itself — on every platform, with a conversion between MQDescriptor and its Descriptor; making or attaching a queue works on Linux and Android. See the module documentation. A descriptor returned or accepted by a generated service reaches AOSP's own AidlMessageQueue (libfmq over libbinder_ndk) intact in both directions, and a queue libfmq made as an ashmem region attaches on the rsbinder side: example-hello/cpp/run_fmq_interop.sh is that check on an emulator.

Tips

  • char is not Rust's char. AIDL char is one UTF-16 code unit and maps to u16; Rust's char is a Unicode scalar value. They are not interchangeable.
  • byte is signed alone and unsigned in an array. A byte parameter is i8, but byte[] elements are u8 — matching how Android treats byte arrays.
  • Every method returns BinderResult<T>, a void one included (BinderResult<()>), because any call can fail in transport.

For more information on AIDL syntax and features, refer to the Android AIDL documentation.

Parcelable

A parcelable is a user-defined struct that can cross the Binder boundary. You declare it in an .aidl file and rsbinder-aidl generates the Rust struct and its codec — it is how any non-trivial interface passes structured data.

Basic Parcelable Definition

A parcelable is declared in its own .aidl file using the parcelable keyword. Here is a simple example:

package com.example;

@RustDerive(Clone=true, PartialEq=true)
parcelable UserProfile {
    int id;
    String name = "Unknown";
    int age = 0;
}

When this AIDL file is processed by rsbinder-aidl, it generates a Rust struct that you can use directly in your service and client code. Several things to note about the definition above:

  • @RustDerive(Clone=true, PartialEq=true) instructs the code generator to add #[derive(Clone, PartialEq)] to the generated Rust struct. By default, generated types do not derive Clone, because some AIDL types contain non-cloneable fields (such as ParcelFileDescriptor or ParcelableHolder). You must opt in explicitly for each type.
  • Default values ("Unknown" for name, 0 for age) are applied in the generated Default trait implementation. Fields without explicit defaults use Rust's default for their type (e.g., 0 for integers, empty string for String).
  • The generated struct can be used directly as a parameter or return type in service interface methods.

You can then use the generated struct in Rust:

#![allow(unused)]
fn main() {
use com::example::UserProfile::UserProfile;

let profile = UserProfile {
    id: 1,
    name: "Alice".into(),
    age: 30,
};

let default_profile = UserProfile::default();
assert_eq!(default_profile.name, "Unknown");
assert_eq!(default_profile.age, 0);
}

Constants in Parcelable

Parcelable types can define constants, including numeric values and bit flags. These are emitted as module-level pub const items inside the generated parcelable module — not as associated constants on the struct. The familiar-looking TypeName::CONSTANT path therefore resolves through the module name (which shares its name with the struct), so you must import the module, not the struct: use ...::Config; gives you both Config::BIT_VERBOSE (the constant) and Config::Config (the struct). Importing the struct directly (use ...::Config::Config;) shadows the module path and makes the constants unreachable through it. This pattern is commonly used for configuration values and flag fields.

@RustDerive(Clone=true, PartialEq=true)
parcelable Config {
    const int MAX_RETRIES = 5;
    const int BIT_VERBOSE = 0x1;
    const int BIT_DEBUG = 0x4;

    int retryCount = MAX_RETRIES;
    int flags = 0;
    String label = "default";
}

In Rust, the constants are accessed through the module path, and the struct through the module-qualified Config::Config spelling:

#![allow(unused)]
fn main() {
use com::example::Config;

let mut cfg = Config::Config::default();
assert_eq!(cfg.retryCount, 5);

cfg.flags = Config::BIT_VERBOSE | Config::BIT_DEBUG;
assert_eq!(cfg.flags, 0x5);
}

This pattern mirrors how the Android test suite defines and uses bit flags within StructuredParcelable, where constants like BIT0, BIT1, and BIT2 are defined alongside the fields that use them.

Using Parcelable in Services

Parcelable types are passed to and from service methods as regular parameters. A common pattern is to pass a mutable reference to a parcelable so that the service can fill in or modify its fields. This is based on the FillOutStructuredParcelable pattern used in the rsbinder test suite. As with the constants above, StructuredParcelable in the snippets below names the generated module: the struct is written module-qualified as StructuredParcelable::StructuredParcelable, while the bit constants (StructuredParcelable::BIT0, StructuredParcelable::BIT2) resolve through the module.

Service implementation:

#![allow(unused)]
fn main() {
fn FillOutStructuredParcelable(
    &self,
    parcelable: &mut StructuredParcelable::StructuredParcelable,
) -> rsbinder::BinderResult<()> {
    parcelable.shouldBeJerry = "Jerry".into();
    parcelable.shouldContainThreeFs = vec![parcelable.f, parcelable.f, parcelable.f];
    parcelable.shouldSetBit0AndBit2 =
        StructuredParcelable::BIT0 | StructuredParcelable::BIT2;
    Ok(())
}
}

Client side:

#![allow(unused)]
fn main() {
let mut parcelable = StructuredParcelable::StructuredParcelable {
    f: 17,
    shouldSetBit0AndBit2: 0,
    ..Default::default()
};

service.FillOutStructuredParcelable(&mut parcelable)?;

assert_eq!(parcelable.shouldBeJerry, "Jerry");
assert_eq!(parcelable.shouldContainThreeFs, vec![17, 17, 17]);
assert_eq!(
    parcelable.shouldSetBit0AndBit2,
    StructuredParcelable::BIT0 | StructuredParcelable::BIT2
);
}

The service receives the parcelable by mutable reference, reads existing field values, and populates the remaining fields before returning. The client can then inspect the modified parcelable.

Nullable Parcelable

The @nullable annotation allows a parcelable parameter or return type to be None. In the generated Rust code, nullable parcelable types are represented as Option<T>.

AIDL declaration:

@nullable Empty RepeatNullableParcelable(in @nullable Empty input);

Service implementation:

#![allow(unused)]
fn main() {
fn RepeatNullableParcelable(
    &self,
    input: Option<&Empty>,
) -> rsbinder::BinderResult<Option<Empty>> {
    Ok(input.cloned())
}
}

When the client passes None, the service receives None and can return None. When a value is provided, standard Option methods like cloned(), map(), and as_ref() work as expected. Note that cloned() requires the parcelable type to derive Clone via @RustDerive(Clone=true).

Recursive Structures

AIDL supports self-referential parcelable types. Mark the recursive field @nullable(heap=true): the inner value then lives on the heap, so the struct has a finite, known size. As in AOSP's Rust backend, such a field is Option<Box<T>> wherever it is, on a cycle or not. heap=true is accepted only on a parcelable or union type; on any other type (a String, an array, an interface) it is rejected, as AOSP rejects it.

Every cycle needs at least one such field. Once one field on the cycle is boxed, the other fields on it stay inline, as in AOSP: in parcelable A { @nullable(heap=true) B b; } parcelable B { A a; }, A.b is Option<Box<B>> and B.a is a plain A. A type reached through an interface handle or a Vec element keeps the enclosing type finite and closes no cycle. A cycle made only of non-@nullable fields or fixed-size arrays (T[N], which store their elements inline) is rejected with aidl::recursive_parcelable, because neither form can be boxed.

rsbinder also accepts a bare @nullable field (no heap=true) that closes a cycle and boxes it, since Rust can represent it. AOSP's aidl rejects that .aidl as a recursive parcelable, so rsbinder-aidl prints a cargo:warning naming the field; add heap=true to keep the file buildable in an Android tree.

AIDL definition (from RecursiveList.aidl in the test suite):

parcelable RecursiveList {
    int value;
    @nullable(heap=true) RecursiveList next;
}

This generates a Rust struct where next has the type Option<Box<RecursiveList>>: @nullable makes it Option, and heap=true adds the Box.

Rust usage (based on the test_reverse_recursive_list test):

#![allow(unused)]
fn main() {
// Build a linked list: [9, 8, 7, ..., 0]
let mut head = None;
for n in 0..10 {
    let node = RecursiveList {
        value: n,
        next: head,
    };
    head = Some(Box::new(node));
}

// Send to service for reversal
let result = service.ReverseList(head.as_ref().unwrap())?;

// Traverse the reversed list: [0, 1, ..., 9]
let mut current: Option<&RecursiveList> = Some(&result);
for n in 0..10 {
    assert_eq!(current.map(|inner| inner.value), Some(n));
    current = current.unwrap().next.as_ref().map(|n| n.as_ref());
}
assert!(current.is_none());
}

Without the Box indirection the Rust compiler would reject the type definition because RecursiveList would need to contain itself directly, leading to an infinite-size type.

ExtendableParcelable and ParcelableHolder

ExtendableParcelable is a pattern that uses ParcelableHolder to support type-safe, extensible data. A ParcelableHolder field can hold any parcelable type, allowing you to extend a parcelable without changing its base definition. This is useful for versioned interfaces where new fields may be added in the future.

AIDL definitions:

parcelable ExtendableParcelable {
    int a;
    @utf8InCpp String b;
    ParcelableHolder ext;
    long c;
    ParcelableHolder ext2;
}

parcelable MyExt {
    int a;
    @utf8InCpp String b;
}

Setting an extension (based on the test_repeat_extendable_parcelable test):

#![allow(unused)]
fn main() {
use std::sync::Arc;

let ext = Arc::new(MyExt {
    a: 42,
    b: "EXT".into(),
});

let mut ep = ExtendableParcelable {
    a: 1,
    b: "a".into(),
    c: 42,
    ..Default::default()
};

ep.ext.set_parcelable(Arc::clone(&ext))
    .expect("error setting parcelable");
}

Sending through a service and retrieving the extension:

#![allow(unused)]
fn main() {
let mut ep2 = ExtendableParcelable::default();
service.RepeatExtendableParcelable(&ep, &mut ep2)?;

assert_eq!(ep2.a, ep.a);
assert_eq!(ep2.b, ep.b);
assert_eq!(ep2.c, ep.c);

let ret_ext = ep2.ext.get_parcelable::<MyExt>()
    .expect("error getting parcelable");
assert!(ret_ext.is_some());

let ret_ext = ret_ext.unwrap();
assert_eq!(ret_ext.a, 42);
assert_eq!(ret_ext.b, "EXT");
}

Key points about ParcelableHolder:

  • Type erasure: The ParcelableHolder stores the extension in a type-erased manner. You must specify the concrete type when calling get_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 ParcelableHolder fields (as shown with ext and ext2 above), 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 ParcelableHolder through without losing data.
  • Passing through over RPC: an undecoded holder can be passed on over kernel binder, and within one RPC session on the android-16 (v2) wire. On the r34 and android-13 v0/v1 wires, and into a different session or transport, writing it returns BadType; decode it with get_parcelable::<T>() first. The ParcelableHolder rustdoc lists each case.

Generic Parcelables

A parcelable may take type parameters, as AOSP's android.hardware.common.fmq.MQDescriptor<T, Flavor> does:

parcelable MQDescriptor<@FixedSize T, Flavor> {
    GrantorDescriptor[] grantors;
    NativeHandle handle;
    int quantum;
    int flags;
}

A parameter never reaches the parcel. It is a compile-time label that ties the descriptor to an element type and a flavor, so two instantiations produce the same bytes. The generated struct mirrors AOSP's Rust backend:

#![allow(unused)]
fn main() {
pub struct MQDescriptor<T, Flavor> {
    pub grantors: Vec<GrantorDescriptor>,
    pub handle: NativeHandle,
    pub quantum: i32,
    pub flags: i32,
    pub _phantom_T: core::marker::PhantomData<T>,
    pub _phantom_Flavor: core::marker::PhantomData<Flavor>,
}
}

Every impl (Default, Parcelable, Serialize, Deserialize, ParcelableMetadata) is generic with no bound on the parameters. The phantom fields are pub (AOSP keeps them private) so MQDescriptor { quantum: 4, ..Default::default() } compiles outside the generated module. Note that #[derive(Debug)] and @RustDerive bound the parameters the way any derive does: Foo<Bar>: PartialEq needs Bar: PartialEq.

An annotation on a parameter is a requirement on the argument, checked at every use site: @FixedSize T accepts a primitive, an enum, or a @FixedSize parcelable or union (MQDescriptor<byte, …> compiles, MQDescriptor<String, …> does not; a generic instantiation such as Elem<int> never qualifies, as in AOSP), and @VintfStability T accepts only a @VintfStability declaration. Any other annotation on a parameter is a requirement no argument meets: the Java-only @JavaPassthrough and @JavaSuppressLint parse on the declaration, as in AOSP, but every use of such a generic is rejected. The argument count must match the declaration.

The generator rejects, with a diagnostic naming the reason:

  • a field whose type is a parameter (parcelable Foo<T> { T value; }) — nothing would be written for it;
  • a generic union — an enum with an unused parameter has no Rust form;
  • a nested declaration inside a generic parcelable — AOSP's rule ("Generic types can't have nested types"); a field could only reach it by a path the parameter's name would capture;
  • a parameter named like something the generated Rust spells unqualified: a keyword, String, Vec, Option, Box, Default, core, std, a primitive, the rsbinder crate, or the parcelable itself (parcelable R<R>). A nested type named Vec, Box, Option, String, Default, std or rsbinder is not refused and breaks the generated file instead (see What the compiler rejects);
  • an array, a List or void as a type argument;
  • an annotation on a type argument (Q<@nullable Elem>) — as in AOSP, @nullable belongs to the whole field: @nullable Q<Elem>.

Tips

  • Always use @RustDerive(Clone=true) if you need to clone parcelable values. This is required for patterns like input.cloned() with nullable parameters. Only add it when all fields in the parcelable actually implement Clone.

  • Use @RustDerive(PartialEq=true) when you need to compare parcelable instances in assertions or business logic. As with Clone, all fields must implement PartialEq.

  • Write @nullable(heap=true) on a recursive field. It gives the field the same Option<Box<T>> type AOSP's Rust backend generates, and keeps the .aidl accepted by AOSP's aidl; a bare @nullable there works in rsbinder but draws a warning.

  • Default values in AIDL translate to Rust's Default trait. When you write int count = 5; in AIDL, calling MyParcelable::default() in Rust will produce a struct with count set to 5.

  • 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 .aidl file. Following the AIDL convention, each parcelable type should be defined in a separate file whose name matches the type name (e.g., UserProfile.aidl for parcelable UserProfile).

  • Constants live on the generated module, not the struct. Import the module: MyParcelable::MAX_VALUE is the constant and MyParcelable::MyParcelable is the struct. Importing the struct directly hides the constants.

Enum and Union

Beyond interfaces and parcelables, AIDL has enums — named integer constants — and unions — a value that is one of several types at a time. Both map onto Rust, though the enum mapping is not the one you would guess.

Enum Types

AIDL enums are backed by a specific integer type, declared using the @Backing annotation. Unlike Rust's native enums, AIDL enums map to newtype structs wrapping the backing integer. This design preserves wire compatibility and allows values outside the defined set, which is important for forward compatibility in IPC.

Defining Enums in AIDL

The @Backing(type=...) annotation specifies the underlying integer type. It is optional — when omitted, the backing type defaults to byte. Here are examples for each supported backing type:

Byte-backed enum:

@Backing(type="byte")
enum ByteEnum {
    FOO = 1,
    BAR = 2,
    BAZ,
}

Int-backed enum:

@Backing(type="int")
enum IntEnum {
    FOO = 1000,
    BAR = 2000,
    BAZ,
}

Long-backed enum:

@Backing(type="long")
enum LongEnum {
    FOO = 100000000000,
    BAR = 200000000000,
    BAZ,
}

When a value is omitted (as with BAZ above), it is automatically assigned the previous value plus one. So BAZ would be 3, 2001, and 200000000001 respectively.

Backing Type Mapping

The AIDL backing type determines the Rust integer type used inside the generated newtype struct:

AIDL Backing TypeRust Type
bytei8
inti32
longi64

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 ns initialized to an empty array {}. A first field without an initializer takes its type's Rust Default, as in AOSP's Rust backend; for an enum that is the backing value 0, not the first enumerator.
  • Fields can have different types, including primitives, strings, arrays, other AIDL types, and even binder references.
  • Constants can be defined inside unions, independent of the union's variants.
  • @RustDerive is recommended so the generated Rust type supports Clone and PartialEq.

Using Unions in Rust

The AIDL union generates a Rust enum. Because AIDL types are organized into modules, the union type and its enum variants live inside a module named after the union. Variants are accessed as Union::Union::VariantName(...). A variant's name is its field's name with only the first letter uppercased, as in AOSP's Rust backend: ns becomes Ns, nullable_iface becomes Nullable_iface, and URL stays URL. Two fields whose names differ only in the first letter's case (foo and Foo) are therefore rejected, as AOSP rejects them.

#![allow(unused)]
fn main() {
// Default value is the first field
assert_eq!(Union::Union::default(), Union::Union::Ns(vec![]));

// Creating union variants
let u1 = Union::Union::N(42);
let u2 = Union::Union::S("hello".into());
let u3 = Union::Union::Be(ByteEnum::FOO);
}

Constants defined inside a union are accessed directly on the module, not through a variant:

#![allow(unused)]
fn main() {
// Constants defined in the union
let s = Union::S1;  // "a string constant in union"

// Using a constant as a union variant value
let u = Union::Union::S(Union::S1.to_string());
}

Union Tags

Each union has an associated Tag type that identifies which variant is currently active. (Like AIDL enums, it is generated as a newtype struct whose variants are exposed as associated constants such as Union::Tag::n. It is backed by i32, or by i8 in a @FixedSize union, as in AOSP.) Tags are useful when you need to inspect or communicate which field a union holds without extracting the value itself.

#![allow(unused)]
fn main() {
let result = service.GetUnionTags(&[
    Union::Union::N(0),
    Union::Union::Ns(vec![]),
])?;
assert_eq!(result, vec![Union::Tag::n, Union::Tag::ns]);
}

Tags can also be used in match expressions when implementing service logic. Here is an example from the test service implementation:

#![allow(unused)]
fn main() {
fn GetUnionTags(
    &self,
    input: &[Union::Union],
) -> Result<Vec<Union::Tag>> {
    Ok(input.iter().map(|u| match u {
        Union::Union::Ns(_) => Union::Tag::ns,
        Union::Union::N(_) => Union::Tag::n,
        Union::Union::M(_) => Union::Tag::m,
        Union::Union::S(_) => Union::Tag::s,
        Union::Union::Ibinder(_) => Union::Tag::ibinder,
        Union::Union::Ss(_) => Union::Tag::ss,
        Union::Union::Be(_) => Union::Tag::be,
    }).collect())
}
}

Unions Containing Enums (EnumUnion)

Unions can contain enum types as fields and specify default values using enum constants:

@RustDerive(Clone=true, PartialEq=true)
union EnumUnion {
    IntEnum intEnum = IntEnum.FOO;
    LongEnum longEnum;
    /** @deprecated do not use this */
    int deprecatedField;
}

In this example, the default value is the first field (intEnum) initialized to IntEnum.FOO. In Rust:

#![allow(unused)]
fn main() {
assert_eq!(EnumUnion::default(), EnumUnion::IntEnum(IntEnum::FOO));
}

A /** @deprecated note */ block on a field becomes #[deprecated = "note"] on the generated variant (#[deprecated] when there is no note). The generated module allows the lint for its own plumbing, so only your code's uses warn — which fails a -D warnings build until you migrate or allow it.

Nested Unions

Unions can also be nested inside other unions:

union UnionInUnion {
    EnumUnion first;
    int second;
}

This allows building complex tagged-value hierarchies that are fully type-safe on the Rust side.

Tips

  • Specify @Backing explicitly. It is optional — the default is byte — but stating it makes the wire format unambiguous to a reader.
  • Handle values you never declared. An AIDL enum is an integer on the wire, so a peer built against a newer definition can send one the current enum does not name. That is the point of the newtype mapping; do not assume the set is closed.
  • Tags keep the AIDL field names, so they are lowercase: Union::Tag::ns, not Union::Tag::Ns.

Further Reading

  • Android AIDL documentation -- the upstream reference for AIDL syntax and semantics
  • AIDL annotation reference -- details on @Backing, @RustDerive, and other annotations
  • The rsbinder test suite at tests/aidl/ and tests/src/test_client.rs contains comprehensive examples of enum and union usage

AIDL Annotations

Annotations modify what the code generator emits from an .aidl file — trait derivation, backing types, nullability, interface stability. Some that AOSP enforces are recognized here but not checked; each section below says which.

If you have not read AIDL Data Types yet, start there: annotations adjust those mappings rather than replacing them.

@RustDerive

The @RustDerive annotation tells the code generator to add Rust derive attributes to parcelable and union types. Without this annotation, generated types receive only the minimum set of derives needed for serialization.

@RustDerive(Clone=true, PartialEq=true)
parcelable Point {
    int x;
    int y;
}

This generates a Rust struct with both Clone and PartialEq derived:

#![allow(unused)]
fn main() {
#[derive(Debug, Clone, PartialEq)]
pub struct Point {
    pub x: i32,
    pub y: i32,
}
}

Available Derives

DeriveDescription
CloneEnables cloning of the type
PartialEqEnables equality comparison
CopyEnables bitwise copy (fixed-size types only)

These are the most common choices, but the list is not exhaustive: as in AOSP, the generator passes through any Name=true identifier as a derive, so other derivable traits (Eq, Hash, PartialOrd, Ord, ...) work the same way.

Why Clone Is Not Derived by Default

Generated types do not derive Clone by default. This is intentional because some AIDL types contain fields that cannot be cloned:

  • ParcelFileDescriptor wraps an OwnedFd, 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.
  • ParcelableHolder contains a Mutex, 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 BackingRust TypeSize
"byte"i81 byte
"int"i324 bytes
"long"i648 bytes

If no @Backing annotation is specified, the default backing type is "byte".

Choose the smallest backing type that fits your range of values. Enum values are serialized to the Binder transaction as their backing integer type, so a smaller backing type produces a more compact wire format.

@nullable

The @nullable annotation marks a type as optional, indicating that the value may be absent. In Rust, this maps to Option<T>.

Basic Usage

Apply @nullable to method parameters, return values, or struct fields:

interface IUserService {
    @nullable String getName();
    void setValues(in @nullable int[] values);
}

The generated Rust signatures use Option:

#![allow(unused)]
fn main() {
fn getName(&self) -> rsbinder::BinderResult<Option<String>>;
fn setValues(&self, values: Option<&[i32]>) -> rsbinder::BinderResult<()>;
}

When None is passed over a Binder transaction, a null marker is written to the parcel. The receiving side deserializes it as None without allocating any data.

Heap-Allocated Nullable: @nullable(heap=true)

AOSP AIDL uses @nullable(heap=true) to mark fields of recursive or self-referential parcelables, so that the C++ and Java backends know to allocate the inner value on the heap:

parcelable RecursiveList {
    int value;
    @nullable(heap=true) RecursiveList next;
}

The Rust backend generates:

#![allow(unused)]
fn main() {
pub struct RecursiveList {
    pub value: i32,
    pub next: Option<Box<RecursiveList>>,
}
}

The Box indirection is necessary because without it, RecursiveList would contain itself directly, making the type infinitely large.

As in AOSP's Rust backend, a heap=true field is Option<Box<T>> wherever it is, on a cycle or not, and a method argument or return keeps its usual type. heap=true is accepted only on a parcelable or union type, and heap is the annotation's only parameter; anything else is an error, as in AOSP.

rsbinder also boxes a bare @nullable field that closes a reference cycle — a cycle of any length, not just a direct self-reference — because Rust can represent it. A path through an interface handle or a Vec element already keeps the type finite and closes no cycle. AOSP's aidl rejects a bare @nullable on a cycle, so rsbinder-aidl prints a cargo:warning for it; write heap=true if the same AIDL must also compile under the AOSP toolchain.

For non-recursive optional fields, plain @nullable is sufficient and avoids the extra heap allocation.

@utf8InCpp

The @utf8InCpp annotation exists in Android AIDL to specify UTF-8 encoding for strings in the C++ backend, where the default encoding is UTF-16. In rsbinder, this annotation has no effect because Rust strings are always UTF-8.

interface ITextService {
    @utf8InCpp String getData();
    @utf8InCpp List<String> getNames();
}

Both String and @utf8InCpp String produce identical Rust type mappings:

DirectionRust Type
Input (in)&str
Output / ReturnString

You may encounter this annotation in AIDL files that were originally written for Android's C++ backend. It is safe to keep or remove it when targeting rsbinder; the generated Rust code is identical either way.

@Descriptor

The @Descriptor annotation overrides the interface descriptor string that identifies an interface on the Binder wire protocol. This is useful when renaming an interface while maintaining backward compatibility with existing clients or services.

Every Binder interface has a descriptor string derived from its fully qualified name (e.g., android.aidl.tests.IOldName). When you rename an interface, the descriptor changes, breaking compatibility. The @Descriptor annotation lets you decouple the source name from the wire descriptor.

Consider an interface that was originally named IOldName:

// IOldName.aidl
interface IOldName {
    String RealName();
}

You can create a new interface INewName that uses the same descriptor:

// INewName.aidl
@Descriptor(value="android.aidl.tests.IOldName")
interface INewName {
    String RealName();
}

Because both interfaces share the same descriptor, they are interchangeable at the Binder level:

#![allow(unused)]
fn main() {
// A service registered as IOldName can be used as INewName
let new_from_old = old_service
    .as_binder()
    .into_interface::<dyn INewName::INewName>();
assert!(new_from_old.is_ok());
}

This is particularly useful during interface migrations where you want to rename types in your codebase without requiring all clients and services to update simultaneously.

@VintfStability

The @VintfStability annotation marks a type or interface as part of the Vendor Interface (VINTF). VINTF-stable types are subject to stricter compatibility rules to ensure that vendor and system partitions can be updated independently.

@VintfStability
parcelable VintfData {
    int value;
}

Stability Rules

In the upstream Android toolchain, VINTF-stable types are meant to contain only other VINTF-stable types so that their serialization format stays stable across independent system/vendor updates — critical for framework↔HAL compatibility.

rsbinder's generator stamps the generated type or interface with Stability::Vintf and enforces the rule at compile time: a @VintfStability declaration that references a type which is not VINTF-stable is an error. AOSP enforces the same rule through aidl_interface { stability: "vintf" }; rsbinder has no such build mode and checks the references instead, which rejects the same contracts. A @RustOnlyStableParcelable is exempt, as AOSP exempts a stable-API parcelable, and a non-VINTF type may still use a VINTF one.

A declaration nested inside a @VintfStability type is VINTF-stable too, matching AOSP's scoped lookup. At run time, ParcelableHolder::set_parcelable on a VINTF holder returns BadValue for a payload whose stability does not include VINTF.

@FixedSize

The @FixedSize annotation indicates that a parcelable has a fixed serialization size, meaning its wire format is always the same number of bytes regardless of the field values.

@FixedSize
parcelable FixedPoint {
    int x;
    int y;
}

Constraints

A @FixedSize parcelable or union may only contain:

  • Primitive types (boolean, byte, char, int, long, float, double)
  • Enums
  • Other @FixedSize parcelables and unions
  • Fixed-size arrays (int[4]) of the above

and may not contain String, IBinder, ParcelFileDescriptor, ParcelableHolder, an interface, a variable-length array (T[]), a List<T>, or a @nullable type. @FixedSize is not inherited by nested declarations.

rsbinder-aidl rejects a field that breaks these rules, porting AOSP's CanBeFixedSize. The annotation changes the generated code in one place: a @FixedSize union's implicit Tag is backed by byte (Tag(pub i8)) instead of int, as AOSP's UnionTagGenerater declares it, so a Tag[] goes on the wire as byte[], and such a union may have at most 128 fields. The layout and wire format of every field are otherwise unchanged.

Relationship with @RustDerive(Copy=true)

Copy is derived purely from @RustDerive(Copy=true); @FixedSize is not a prerequisite for it in rsbinder. Conceptually you should still only add Copy to types with a fixed layout, so pairing the two documents intent:

@RustDerive(Clone=true, Copy=true, PartialEq=true)
@FixedSize
parcelable Coordinate {
    double latitude;
    double longitude;
}

@EnforcePermission

@EnforcePermission declares the Android permission(s) a method requires. The generated on_transact arm checks the caller against PermissionManagerService before your handler runs and returns EX_SECURITY if the check fails — so the handler body needs no authorization code:

interface IExample {
    @EnforcePermission("android.permission.INTERNET")
    void doNetworking();

    @EnforcePermission(allOf = {"android.permission.A", "android.permission.B"})
    void needsBoth();

    @EnforcePermission(anyOf = {"android.permission.A", "android.permission.B"})
    void needsEither();
}

The companion documentation-only annotations @PermissionManuallyEnforced and @RequiresNoPermission are recognized and emit no runtime check (they exist so every method can declare its permission posture).

Kernel-only. Android permissions are a kernel-binder concept. Over the RPC transport an @EnforcePermission method is denied (EX_SECURITY) unconditionally — granting would mean granting root to an anonymous peer. For RPC authorization use the transport-native mechanisms, or inject a PermissionAuthority to back the check with your own policy. See Security & Authorization.

Summary

AnnotationApplies ToRust Effect
@RustDeriveparcelable, unionAdds derive attributes (Clone, Copy, PartialEq)
@BackingenumSets the backing integer type (i8, i32, i64)
@nullablefield, param, returnMaps to Option<T>
@nullable(heap=true)fieldheap=true is ignored; rsbinder boxes a field its own cycle analysis finds recursive
@utf8InCppStringNo effect in Rust (strings are always UTF-8)
@DescriptorinterfaceOverrides the wire descriptor string
@VintfStabilityparcelable, interface, type parameterStamps Stability::Vintf; every referenced type must be VINTF-stable (checked). On a type parameter (Foo<@VintfStability T>): the argument must be a @VintfStability declaration
@FixedSizeparcelable, union, type parameterFields checked to be fixed-size; no field layout change, but a union's Tag is i8-backed (Copy comes from @RustDerive(Copy=true)). On a type parameter (MQDescriptor<@FixedSize T, Flavor>): the argument must itself be fixed-size
@deprecated (javadoc)any declaration, method, field, constant, enumeratorEmits #[deprecated] / #[deprecated = "note"] from the first /** … @deprecated … */ among the comments before the item (AOSP FindDeprecated, android-14.0.0_r50+). A comment written between the item's annotations, type and name is not read
@EnforcePermissioninterface methodGenerates a PermissionManagerService check (kernel-only; denied over RPC)

When writing AIDL files for rsbinder, the most commonly used annotations are @RustDerive (for ergonomic Rust types), @Backing (for enums), and @nullable (for optional values). The remaining annotations are important for interoperability with Android or for specific use cases like recursive types and interface migration.

Interface Macros (no .aidl)

#[rsbinder::interface] declares a Binder interface as a Rust trait — no .aidl file, no build.rs, no generated-code directory:

rsbinder = { version = "0.12", features = ["macros"] }
#![allow(unused)]
fn main() {
use rsbinder::{interface, BinderResult, Strong, Uri};

#[interface(descriptor = "com.example.IHello")]
pub trait IHello {
    fn echo(&self, msg: &str) -> BinderResult<String>;
    fn fill(&self, values: &mut Vec<i32>) -> BinderResult<()>;   // `&mut` = out
    #[oneway]
    fn ping(&self) -> BinderResult<()>;
}

// `Hello` is your `impl IHello`, plus `impl Interface for Hello {}`.
rsbinder::serve(Uri::kernel())?.add("hello", BnHello::new_binder(Hello))?.run()?;
let hello: Strong<dyn IHello> = rsbinder::connect(Uri::kernel().with_service("hello"))?;
}

BnHello, BpHello and IHelloDefault appear where you wrote the trait, so the service impl, the registration and the call sites read exactly as they do on the .aidl path.

The macro is not a second code generator: it fills the same rsbinder_aidl::render structs the AIDL front-end fills and runs the same templates. A trait here and the equivalent .aidl produce byte-for-byte identical code, enforced by golden tests. Moving between the two never touches a call site and never changes a byte on the wire.

The feature is off by default, because it pulls the AIDL compiler in as a proc-macro dependency — a build-time cost that a crate consuming generated code has no reason to carry.

Which path to use

UseWhen
#[rsbinder::interface]rsbinder on both ends, wire private to both
.aidlAn Android service or client, another language on the other end, or a published interface

.aidl is also the only path that carries unions, interface constants, @VintfStability, @EnforcePermission, ParcelableHolder fields, generics and nested types. The macro has no syntax for those; where a Rust declaration could be mistaken for one, it is a compile error naming the reason.

What a signature means

SignatureMeaning
x: i32 — an AIDL scalar, or a bare name that may be an enum (asserted Copy)in argument, by value
x: &Cfg, x: &str, x: &[T], Option<&T> — every other in type borrowsin argument
x: &mut Tout — the server fills the caller's value
#[inout] x: &mut Twritten and read back
Option<T>@nullable
#[oneway] on a methodno reply; must return BinderResult<()>
#[deprecated] / #[deprecated = "…"]AIDL's @deprecated, on the trait, a method, a #[derive(Parcelable)] struct or a field

The macro accepts only what .aidl renders, spelled the way .aidl renders it, so moving the interface to .aidl later never changes a call site. A shape with no .aidl equivalent is a compile error naming the form to use:

  • Only AIDL's scalars: bool, i8, i32, i64, f32, f64, u16, and u8 as an array element. u32, u64, i16, usize, u128, Rust's char, and i8 inside an array are refused.
  • An in argument other than a scalar or an enum is borrowed: &str rather than String or &String, &[T] rather than Vec<T> or &Vec<T>, Option<&T> rather than &Option<T>.
  • A primitive or a String cannot be out — .aidl passes them only in, and @nullable does not change that (&mut Option<String> is refused too). Return the value, or use a Vec<T> or a parcelable.
  • A binder object (Strong<dyn IFoo>, SIBinder) cannot be out or inout, and a ParcelFileDescriptor cannot be out, Option or not — AOSP aidl refuses them. Return the object, pass the fd #[inout], or use an array, which takes every direction (out: &mut Vec<Option<_>>, #[inout]: &mut Vec<_>).
  • A primitive has no null form, so Option<i32> is refused; so is Option<Mode> for a derived enum, which travels as its repr scalar.
  • A borrowed type nested inside another (&[&str]) has nothing to borrow from once decoded. Use the owned form.
  • BinderResult<T, E>, () or a trait object as an argument, and a duplicate method or argument name are refused. #[deprecated(since = …)] and note = … are refused too, because @deprecated has nowhere to put them.

The exact spelling for every array and Option combination, in each direction, is the type table in the rsbinder-macros docs.

Methods are declared fn, not async fn. The async feature emits the IFooAsync halves from the same declaration.

Data types

#![allow(unused)]
fn main() {
use rsbinder::{BinderEnum, Parcelable};

#[derive(Parcelable, Default, Debug, Clone, PartialEq)]
#[parcelable(descriptor = "com.example.Config")]
pub struct Config {
    pub name: String,
    pub retries: i32,
    pub extra: Option<Vec<u8>>,
}

#[derive(BinderEnum, Default, Clone, Copy, Debug, PartialEq, Eq)]
#[repr(i32)]
pub enum Mode {
    #[default]
    Fast = 0,
    Safe = 1,
}
}

#[derive(Parcelable)] emits only the codec, leaving Default, Debug and the rest to the usual derives. Default is required: decoding starts from it, which is how a field an older writer never wrote gets a value. Named fields only — field order is wire order, so append only, as in .aidl.

#[derive(BinderEnum)] needs #[repr(i8|i32|i64)], the AIDL backing type that goes on the wire, and an explicit value per variant so the wire value is visible at the declaration.

A derived enum is closed. An undeclared value decodes as BadValue, where an .aidl enum carries it through. For a peer that may send a value your build has never heard of, use .aidl or declare_binder_enum!.

Either type works with to_bytes / from_bytes.

Rules that bite

Reordering methods is a wire break. Transaction codes follow declaration order (FIRST_CALL_TRANSACTION + i), as in .aidl without explicit codes. Append at the end.

Set the descriptor for anything shared. It defaults to the bare trait name, which is fine inside one crate and not across two.

Spell out-parameter types directly. A proc macro cannot see through a type alias, so &mut Ids for type Ids = Vec<i32> reads as an opaque named type and loses the length word .aidl writes for an out vector. Path qualification (std::vec::Vec<i32>) is matched structurally and is fine; an alias is not.

A bare path in a signature is your own, super:: is not. The generated body lands in a module one level below where you wrote the macro and reaches your scope through use super::*;, so super::X names that module — a signature meaning the parent has to say crate::X.

Names starting with __Rsb are reserved. The async half declares its type parameters and a helper struct (__RsbPool, __RsbService, __RsbRuntime, __RsbAsyncWrapper) in the scope the signatures resolve in. The macro refuses a trait or a bare signature path starting with __Rsb; reach such a type through crate::…. .aidl refuses type declaration names with the same prefix. The same glob import means a mod rsbinder or use … as rsbinder in your module is what the generated rsbinder:: paths resolve to.

Service Development

Once your interface compiles, the rest is the service that implements it and the client that calls it. This part of the guide collects the runtime patterns you will use day-to-day.

If you have not yet built a working service, start with Hello, World! and come back.

Chapters

  • Service Patterns — The three pieces every rsbinder service needs (state struct, impl Interface, impl IYourService) and how to combine them for single- and multi-interface processes.
  • Async Service — Implementing services with async fn on top of the Tokio runtime, and how the async traits differ from their sync counterparts.
  • Callbacks and Interfaces — Passing IBinder objects in either direction, managing callback collections, and watching for remote process death.
  • ParcelFileDescriptor — Sending pipes, files, and sockets across the Binder boundary as owned file descriptors.
  • Shared Memory — Sharing a mapped region instead of copying it, for payloads a transaction should not carry.
  • Error Handling — The StatusCode / Status split, 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 native servicemanager; and how it relates to the RPC transport.

Each builds on the one before it, but to ship a working service you only need Service Patterns and Service Manager (HUB); the rest fill in capabilities as you need them.

Service Patterns

How to structure a service, from a single method to a process hosting several.

This chapter uses the low-level lifecycle — ProcessState, the thread pool, hub::add_service — because that is what the pieces actually are. rsbinder::serve(Uri::kernel())?.add(name, binder)?.run()? performs exactly these steps and is the shorter way to write them; reach past it when you need control a Uri does not express. See Cross-Transport Services.

Basic Service Structure

Every Binder service in rsbinder requires three pieces:

  1. A struct that holds service state.
  2. An impl Interface block for the struct, optionally providing a dump() method.
  3. An impl IYourService block for the struct, implementing the AIDL-defined methods.
#![allow(unused)]
fn main() {
use rsbinder::*;

// 1. Define a struct to hold service state.
//    Use #[derive(Default)] when you want to construct with ::default().
#[derive(Default)]
struct MyService {
    // Add fields here to maintain service state.
    // Use Mutex<T> or RwLock<T> for fields that need interior mutability,
    // since method receivers are &self (shared references).
}

// 2. Implement the Interface trait (required for all services).
//    The dump() method is optional but recommended for debugging.
impl Interface for MyService {
    fn dump(&self, writer: &mut dyn std::io::Write, args: &[String]) -> Result<()> {
        for arg in args {
            writeln!(writer, "{arg}").unwrap();
        }
        Ok(())
    }
}

// 3. Implement your AIDL-generated interface trait.
impl IMyService::IMyService for MyService {
    fn echo(&self, input: &str) -> rsbinder::BinderResult<String> {
        Ok(input.to_owned())
    }
}
}

Note that all AIDL method implementations receive &self, not &mut self. If your service needs mutable state, wrap the relevant fields in std::sync::Mutex or std::sync::RwLock. The test suite demonstrates this pattern with a HashMap protected by a Mutex:

#![allow(unused)]
fn main() {
#[derive(Default)]
struct TestService {
    service_map: Mutex<HashMap<String, rsbinder::Strong<dyn INamedCallback::INamedCallback>>>,
}
}

Service Registration and Main Loop

A service binary follows a consistent lifecycle: initialize the process state, start the thread pool, create and register services, then block on the thread pool. Here is the standard pattern based on the project's test service implementation:

fn main() -> std::result::Result<(), Box<dyn std::error::Error>> {
    // Initialize ProcessState. This opens the Binder device and configures
    // the process for Binder IPC. Must be called before any Binder operations.
    // `init_default()` returns `Result<&'static ProcessState, ...>`; use `?`
    // to surface device-open or initialization failures.
    ProcessState::init_default()?;

    // Start the binder thread pool and enable kernel-driven thread
    // spawning, so concurrent transactions are handled in parallel.
    ProcessState::start_thread_pool();

    // Create a Binder object from your service implementation.
    // BnMyService is the server-side stub generated by the AIDL compiler.
    let service = BnMyService::new_binder(MyService::default());

    // Register the service with the service manager (hub).
    // The first argument is the service name used by clients to find it.
    // `add_service` accepts anything convertible into `SIBinder`, so the
    // typed `Strong<dyn IMyService>` goes in directly — no `.as_binder()`.
    hub::add_service("com.example.myservice", &service)?;

    // Block the main thread and process incoming Binder transactions.
    // This call does not return under normal operation.
    Ok(ProcessState::join_thread_pool()?)
}

Each function in this lifecycle serves a specific purpose:

  • ProcessState::init_default() opens the Binder device (typically /dev/binderfs/binder) and sets up process-wide state for Binder communication.
  • ProcessState::start_thread_pool() spawns a binder worker thread and enables kernel-driven spawning of further workers. Without this call, transactions are served only by threads that explicitly call join_thread_pool(), so the process is effectively single-threaded.
  • BnMyService::new_binder() wraps your implementation struct in a Binder-compatible object. The Bn prefix stands for "Binder native" (the server-side stub).
  • hub::add_service() registers your service with the service manager so that clients can discover it by name.
  • ProcessState::join_thread_pool() makes the calling thread join the Binder thread pool, blocking it to process transactions indefinitely.

Multiple Services in One Process

A single process can host multiple Binder services. The test suite registers four distinct services in one binary. Each service gets its own struct, its own Interface and AIDL trait implementations, and its own registration call:

fn main() -> std::result::Result<(), Box<dyn std::error::Error>> {
    ProcessState::init_default()?;
    ProcessState::start_thread_pool();

    // Register the primary test service.
    let service = BnTestService::new_binder(TestService::default());
    hub::add_service(test_service_name, &service)?;

    // Register a versioned interface service.
    let versioned_service = BnFooInterface::new_binder(FooInterface);
    hub::add_service(versioned_service_name, versioned_service)?;

    // Register a nested service.
    let nested_service = INestedService::BnNestedService::new_binder(NestedService);
    hub::add_service(nested_service_name, nested_service)?;

    // Register a fixed-size array service.
    let fixed_size_array_service =
        IRepeatFixedSizeArray::BnRepeatFixedSizeArray::new_binder(FixedSizeArrayService);
    hub::add_service(fixed_size_array_service_name, fixed_size_array_service)?;

    // All services share the same thread pool and process state.
    Ok(ProcessState::join_thread_pool()?)
}

All services in a process share the same ProcessState and thread pool. You only need to call ProcessState::init_default() and ProcessState::start_thread_pool() once, regardless of how many services you register.

Implementing dump()

The dump() method is part of the Interface trait and provides a way to inspect service state at runtime. It is optional -- if you do not override it, the default implementation does nothing. However, implementing it is valuable for debugging and diagnostics.

The method receives a writer and a list of string arguments:

#![allow(unused)]
fn main() {
impl Interface for MyService {
    fn dump(&self, writer: &mut dyn std::io::Write, args: &[String]) -> Result<()> {
        for arg in args {
            writeln!(writer, "{arg}")?;
        }
        Ok(())
    }
}
}

An Err from dump() is the reply status the caller receives. A write to a pipe whose reader has exited fails with EPIPE, which ? converts to StatusCode::DeadObject; rsbinder sends a handler's DeadObject as FailedTransaction, because an AOSP BpBinder that receives DEAD_OBJECT marks the proxy dead and fails every later call on it. The same substitution applies to every method a service implements.

On the client side, you can invoke dump() on a remote service through its proxy. The output is written to a file descriptor (typically a pipe):

#![allow(unused)]
fn main() {
let (read_end, write_end) = rsbinder::ParcelFileDescriptor::pipe()?;
let args = vec!["dump".to_owned(), "MyService".to_owned()];

service.as_binder().as_proxy().unwrap().dump(write_end, &args)?;

let mut buf = String::new();
(&read_end).read_to_string(&mut buf)?;
// buf now contains the dump output
}

Testing Service Liveness with ping_binder()

The ping_binder() method tests whether a service is reachable and responsive. It sends a lightweight ping transaction and returns Ok(()) on success:

#![allow(unused)]
fn main() {
let hello: Strong<dyn IHello> = rsbinder::connect(Uri::kernel().with_service("my.hello"))?;
assert_eq!(hello.as_binder().ping_binder(), Ok(()));
}

This is useful for health checks and for verifying that a service is still alive before making more expensive calls.

Default Implementation Pattern

rsbinder supports a default implementation pattern that provides fallback behavior when a method is not implemented by the remote service. This is especially useful for forward compatibility: a client compiled against a newer AIDL interface can still communicate with an older service that does not implement all methods.

To set up a default implementation:

  1. Define a struct that implements the Default trait variant of your interface.
  2. Wrap it in an Arc and register it with setDefaultImpl.
  3. When the remote service returns StatusCode::UnknownTransaction for a method, the default implementation is called instead.
#![allow(unused)]
fn main() {
// Define a default implementation for methods the server may not support.
struct MyDefaultImpl;

impl rsbinder::Interface for MyDefaultImpl {}

impl IMyServiceDefault for MyDefaultImpl {
    fn UnimplementedMethod(&self, arg: i32) -> std::result::Result<i32, Status> {
        // Provide fallback logic.
        Ok(arg * 2)
    }
}

// Register the default implementation globally for this interface.
let di: IMyServiceDefaultRef = Arc::new(MyDefaultImpl);
<BpMyService as IMyService::IMyService>::setDefaultImpl(di);

// When the remote service does not implement UnimplementedMethod,
// the default implementation is used transparently.
let result = service.UnimplementedMethod(100);
assert_eq!(result, Ok(200));
}

Note that setDefaultImpl is a static method on the proxy type (BpMyService). Once registered, the default implementation applies to all proxies of that interface within the process.

Client-Side Patterns

Getting a Service Proxy

Clients obtain a typed proxy to a remote service through the hub module:

fn main() -> std::result::Result<(), Box<dyn std::error::Error>> {
    // ProcessState must be initialized before any Binder operations.
    ProcessState::init_default()?;

    // Obtain a strongly-typed proxy for the service.
    // hub::wait_for_interface blocks until the service is registered and
    // returns a Strong<dyn IMyService> on success. Use hub::check_interface
    // instead for a non-blocking probe that fails immediately with
    // NameNotFound when the service is absent.
    let service: rsbinder::Strong<dyn IMyService::IMyService> =
        hub::wait_for_interface("com.example.myservice")?;

    // Call service methods through the proxy.
    let result = service.echo("hello")?;
    println!("Got: {result}");

    Ok(())
}

A purely-outbound client like this one needs no binder thread pool. As soon as the client receives inbound calls — a callback object it passed to the service, a service-registration notification, or a death recipient — it must also call ProcessState::start_thread_pool() (see below).

Listing Available Services

You can discover all registered services through the service manager:

#![allow(unused)]
fn main() {
for name in hub::list_services(hub::DUMP_FLAG_PRIORITY_DEFAULT) {
    println!("{name}");
}
}

Service Notifications

Clients can register a callback to be notified when a service is registered:

#![allow(unused)]
fn main() {
struct MyServiceCallback;

impl Interface for MyServiceCallback {}

impl hub::IServiceCallback for MyServiceCallback {
    fn onRegistration(&self, name: &str, _service: &SIBinder) -> rsbinder::BinderResult<()> {
        println!("Service registered: {name}");
        Ok(())
    }
}

let callback = hub::BnServiceCallback::new_binder(MyServiceCallback);
hub::register_for_notifications(SERVICE_NAME, &callback)?;
}

onRegistration is an inbound transaction, so it needs the thread pool running in the client.

Death Recipients

Clients can monitor whether a remote service process is still alive by registering a death recipient. The binder_died callback is invoked if the service process terminates:

#![allow(unused)]
fn main() {
struct MyDeathRecipient;

impl DeathRecipient for MyDeathRecipient {
    fn binder_died(&self, _who: &WIBinder) {
        println!("The remote service has died.");
    }
}

let recipient = Arc::new(MyDeathRecipient);
service.as_binder().link_to_death(
    Arc::downgrade(&(recipient as Arc<dyn DeathRecipient>))
)?;
}

To stop receiving notifications, call unlink_to_death with the same weak reference.

binder_died arrives as an inbound binder command, so it too needs the thread pool running in the client.

Cancelling a Long Operation

Binder has no per-call deadline and no way to abort a transaction in flight, so a long call is stopped the way the Android framework stops one (android.os.ICancellationSignal): the service creates a signal, returns its transport binder to the caller, and the caller sends a oneway cancel() to that binder. Only the service's own loop can act on it.

#![allow(unused)]
fn main() {
use rsbinder::cancel::{CancellationSignal, ICancellationSignal};

fn start_export(&self, path: &str) -> rsbinder::BinderResult<Strong<dyn ICancellationSignal>> {
    let signal = CancellationSignal::new();
    let transport = signal.create_transport();
    let token = signal.token();          // the work keeps this
    std::thread::spawn(move || {
        for chunk in 0..10_000 {
            if token.is_canceled() {
                break;
            }
            // ... one chunk of work
        }
    });
    Ok(transport)
}
}

The caller cancels through the binder it got back. A typed proxy calls transport.cancel()? directly. A method that returns a bare IBinder hands the caller an SIBinder, which cancel_remote takes without compiling the interface a second time:

#![allow(unused)]
fn main() {
rsbinder::cancel::cancel_remote(&transport)?;
}

Points worth knowing before you build on it:

  • The service decides what cancellation means. This carries the notification only; returning a service-specific error saying "cancelled" is the service's call (AOSP likewise throws OperationCanceledException from the service).
  • Delivery is best-effort and unordered against your other calls. The transport is a different binder object than the service, and the kernel does not order a oneway to one against a twoway to another: a caller that cancels and then immediately asks the service what it saw can be answered first. Poll, or report through the result of the operation itself.
  • Only this direction. A caller creating the signal and the service polling it would make every poll a transaction back to the caller — and on the RPC stack it would need the client to have opened incoming connections.
  • Declaring it in .aidl: returning android.os.ICancellationSignal means your crate compiles its own copy of that interface, which is a different Rust type from rsbinder::cancel::ICancellationSignal (the wire is the same). Returning a bare IBinder and cancelling it with cancel_remote avoids the second copy.
  • async: signal.token().canceled().await (the tokio feature) waits instead of polling.

Tips

  • Anything that receives a call needs start_thread_pool() — not only services. A client holding a service-notification callback, a callback object it passed to a service, or a death recipient is receiving inbound transactions, and without a thread pool (or a thread parked in join_thread_pool()) those callbacks and binder_died simply never fire. Only a purely outbound client can skip it.
  • ProcessState::init_default() comes before any binder call and panics if it does not — but only once per process; further calls are harmless no-ops.
  • Name services reverse-domain, com.example.myservice, so two crates in one process cannot collide.
  • Mutable state needs Mutex or RwLock. Every AIDL method takes &self.

Async Service

rsbinder supports async/await with the Tokio runtime, making it straightforward to build non-blocking Binder services. rsbinder's own tokio feature is on by default, but it enables only tokio/rt, tokio/rt-multi-thread and tokio/sync — what the library itself uses (sync carries a death notification into a future; the waits use a condvar, so no time). Your crate declares what its code needs, including the macros feature that #[tokio::main] in the recipes below comes from:

tokio = { version = "1", features = ["rt-multi-thread", "macros"] }
# add "time" for the `sleep` / `timeout` examples, "signal" for `ctrl_c`

If you have not yet read the Hello, World! chapter, it is recommended to do so first -- the async concepts here build on the synchronous service and client covered there.

Async is transport-agnostic

Async lives in the AIDL-generated stubs, not in the transport. The compiler emits the async trait (IMyServiceAsyncService), the async proxy (into_async::<Tokio>()), and the new_async_binder constructor once, and the very same async impl runs unchanged over kernel binder or RPC — including through the transport-agnostic serve / connect entry API.

The reason is that the entry API and the transports only ever move a transport-agnostic SIBinder around (register a binder under a name, look one up). Whether that binder is sync- or async-backed is decided entirely by which constructor produced it (new_binder vs new_async_binder) — a fact the transport never sees. So async needs no transport-specific code: pick the constructor, pick the transport, and the two compose.

This chapter gives one setup recipe, then shows what the RPC variant does and does not change about it.

Under the hood — it is a thread bridge, not a reactor. rsbinder async is not epoll-driven non-blocking I/O. On the client a transact runs on tokio::task::spawn_blocking; on the server each call is wrapped in rt.block_on(..) and driven from a blocking worker thread (the binder thread pool for kernel, the serve worker for RPC). This is the same bridge the kernel binder_tokio path 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.

AspectSyncAsync
Trait nameIMyServiceIMyServiceAsyncService
Method signaturefn method(&self) -> Result<T>async fn method(&self) -> Result<T>
Service creationBnXxx::new_binder(impl)BnXxx::new_async_binder(impl, rt())
Remote call (client)service.Method()service.clone().into_async::<Tokio>().Method().await
Main loopserve(..).run()serve(..).spawn() + std::future::pending().await
RuntimeNot neededTokio runtime required

The AIDL compiler generates both the sync trait (IMyService) and the async trait (IMyServiceAsyncService) from the same .aidl file. You choose which one to implement depending on whether your service needs async capabilities.

Every row above is identical across transports — only the URI passed to serve changes. What does differ is how much slack the runtime flavor has: a current-thread runtime is usable for kernel binder under a condition spelled out below, and not usable at all for an RPC server that calls run(). Both cases come down to the same rule, so the recipe that follows uses a multi-threaded runtime and spawn() throughout.

Setting Up the Tokio Runtime

An async Binder service must run inside a Tokio runtime. One recipe covers both transports:

  1. A multi-threaded runtime (what #[tokio::main] builds by default).
  2. serve(..).add(..) — the same two calls as a sync service.
  3. spawn() rather than run(), so this task is not blocked.
  4. Park on std::future::pending().await.
use rsbinder::*;

#[tokio::main]
async fn main() -> std::result::Result<(), Box<dyn std::error::Error>> {
    // The runtime `block_on` runs each inbound call against. Binder threads are
    // outside it, which is the case `Handle::block_on` is built for.
    let rt = TokioRuntime(tokio::runtime::Handle::current());
    let service = BnMyService::new_async_binder(MyAsyncService::default(), rt);

    // `serve(Uri::kernel())` initializes the kernel `ProcessState`; `add`
    // registers with the service manager. `spawn` starts the binder thread
    // pool and returns — unlike `run`, which joins it and never comes back.
    // Swap the `Uri` for `Endpoint::unix("/tmp/my.sock")` to serve the same
    // service over RPC; nothing else in this file changes, but rsbinder then
    // needs `features = ["rpc"]`.
    let _guard = rsbinder::serve(Uri::kernel())?
        .add("com.example.myservice", &service)?
        .spawn()?;

    // Never resolves, so the process stays alive. To shut down on Ctrl-C,
    // await `tokio::signal::ctrl_c()` here instead and declare `tokio/signal`
    // in your own `Cargo.toml` — rsbinder does not pull that feature in.
    std::future::pending::<()>().await;
    Ok(())
}

example-hello/src/bin/hello_async_service.rs is this code with a real interface.

Things worth naming:

  • TokioRuntime is what new_async_binder drives each inbound call with. It wraps a Handle, not an owned Runtime: a binder that owns its runtime panics in Runtime::drop when the last handle to it is released on a worker thread.
  • spawn() over run(). For kernel binder, spawn starts one looper and returns; the kernel grows the pool up to max_threads as load arrives. run joins the pool, which blocks the calling thread forever — fine for a sync service, wrong here.
  • pending().await is the async counterpart of ProcessState::join_thread_pool().
  • rt() in the snippets below is this one-liner:
    #![allow(unused)]
    fn main() {
    fn rt() -> TokioRuntime<tokio::runtime::Handle> {
        TokioRuntime(tokio::runtime::Handle::current())
    }
    }

Advanced: a current-thread runtime

A current-thread runtime works too, under one condition that is easy to miss: its owning thread must stay parked inside Runtime::block_on. That is the only thing that runs the runtime — its timer and IO drivers, and the tasks spawned on it. Park the owner anywhere else — calling ProcessState::join_thread_pool() directly, joining a thread, a blocking read — and every handler that awaits a timer, IO, or a task it spawned stops making progress. There is no error; those calls simply never return.

So the recipe is the same shape as above, with the owner parked:

#![allow(unused)]
fn main() {
let runtime = tokio::runtime::Builder::new_current_thread()
    .enable_all()
    .build()?;

runtime.block_on(async {
    let rt = TokioRuntime(tokio::runtime::Handle::current());
    let service = BnMyService::new_async_binder(MyAsyncService::default(), rt);
    let _guard = rsbinder::serve(Uri::kernel())?.add("com.example.myservice", &service)?.spawn()?;
    std::future::pending::<()>().await;
    Ok(())
})
}

tests/src/bin/test_service_async.rs is built this way, and the integration suite runs the whole client test suite against it — on a manual workflow dispatch, not on every pull request. The multi-threaded recipe carries no such condition to get wrong, which is why it is the default advice.

Calling a local async service through its sync handle

new_async_binder hands back a synchronous Strong<dyn IMyService>. Each of its methods is implemented as block_on(inner.method()), so calling it from inside the runtime — a gateway that calls its own service, start-up code, a test — re-enters the runtime. On a multi-threaded runtime rsbinder handles that by releasing the worker's core first.

Two independent things decide what such a call does. Read them as one list and you will attribute a failure to the wrong one.

Where you call from decides whether the call runs. Tokio refuses a block_on on a thread already executing inside a runtime, and block_in_place — which steps back out of one — exists only for the multi-threaded runtime. That is the whole rule:

The calling threadResult
Not executing inside a runtimeruns
Inside a multi-threaded runtimeruns — releasing the core first from a worker holding one, inline otherwise
Inside a multi-threaded runtime, within a LocalSetpanic — block_in_place is refused there
Inside a current-thread runtimepanic — "Cannot start a runtime from within a runtime"

Applying it takes some care, because "inside a runtime" is not "on a runtime's thread":

  • A spawn_blocking pool thread is outside the runtime it belongs to, either flavor. Those calls run.
  • A Runtime::block_on body is inside — so a current-thread runtime's Runtime::block_on body is the last row, not the first.
  • An async handler is inside for as long as block_on is driving it, and inside the handle's runtime whatever thread it started on. A service whose TokioRuntime holds a current-thread handle therefore cannot reach a second local service through a sync handle from inside a handler — that is the last row, and it is the gateway pattern exactly. Use into_async there, or give the service a multi-threaded handle.

Which runtime the handle points at decides whether the call finishes, whatever the answer above. The future itself is polled on the calling thread either way, and that is all Handle::block_on does. Whatever the handler needs that runtime's scheduler to advance — a timer, IO readiness, or a tokio::spawned task — needs that runtime to be running:

The TokioRuntime handleResult
A multi-threaded runtimecompletes — its workers are always running
A current-thread runtime with a thread parked in Runtime::block_oncompletes — that parked call is what runs it
A current-thread runtime with nobody parked in itnever returns, for a handler that awaits any of the three above. One that needs none of them — ready on the first poll, or suspending only on yield_now — still completes

spawn_blocking is not in that set: the blocking pool runs independently of the scheduler, so awaiting one completes here too. That is what makes an outbound call to another service work from a handler on a current-thread handle — rsbinder issues those through spawn_blocking — where a tokio::spawned task in the same place would hang.

The cost is one core release per call made from a worker thread; from the other running positions the call is inline. A released core is offered to the spawn_blocking pool, which spawns a thread on demand — so it goes unclaimed only when the pool is at its max_blocking_threads cap with every thread busy. Even then the call completes; what stops is the rest of a single-worker runtime, until it returns. Two workers avoid that.

On a path that calls repeatedly, convert the handle once instead and await it. That route dispatches straight to the async service and never touches block_on:

#![allow(unused)]
fn main() {
let svc = local_sync_handle.into_async::<Tokio>();
svc.method().await?;
}

Async Over RPC

Because async lives in the generated stubs and rsbinder::serve / connect only move an SIBinder, the same async service runs over RPC with no new code. You add the result of new_async_binder and the client converts the looked-up proxy with into_async:

#![allow(unused)]
fn main() {
use async_trait::async_trait;
use rsbinder::*;

struct IHelloService;
impl Interface for IHelloService {}

#[async_trait]
impl IHelloAsyncService for IHelloService {
    async fn echo(&self, echo: &str) -> rsbinder::BinderResult<String> {
        Ok(echo.to_owned())
    }
}
}

The server needs a multi-threaded runtime

An RPC async server is driven by a blocking serve worker that calls rt.block_on(handler) from a thread that is not the runtime's own. That part is fine for either flavor — it is the same position a binder looper is in.

What rules out current_thread here is run(): it blocks the calling thread in the accept loop. On a current-thread runtime that thread is the runtime's owner, and parking it anywhere other than Runtime::block_on leaves nothing running that runtime — the condition from Advanced: a current-thread runtime above. Handlers would then hang the first time one awaits a timer, IO, or a task it spawned. A multi-threaded runtime has no owner thread to strand, so run() is safe on it.

fn main() -> std::result::Result<(), Box<dyn std::error::Error>> {
    // RPC async servers need a MULTI-THREAD runtime: run() below parks this
    // thread in the accept loop, which a current-thread runtime cannot survive.
    let runtime = tokio::runtime::Builder::new_multi_thread()
        .worker_threads(2)
        .enable_all()
        .build()?;
    let rt = TokioRuntime(runtime.handle().clone());

    println!("serving over RPC at /tmp/hello.sock");
    rsbinder::serve(Endpoint::unix("/tmp/hello.sock"))?
        .add("hello", BnHello::new_async_binder(IHelloService {}, rt))?
        .run()?; // blocks, driving this one socket
    Ok(())
}

Use spawn() instead of run() if you want the socket driven on a background thread while main does other work (for instance from inside runtime.block_on).

The client converts the looked-up proxy

The client side is the ordinary connect followed by into_async — or connect_async::<dyn IHelloAsync<Tokio>>(uri).await, which does the blocking connect on spawn_blocking:

#![allow(unused)]
fn main() {
let runtime = tokio::runtime::Builder::new_multi_thread().enable_all().build()?;

runtime.block_on(async {
    let uri = Endpoint::unix("/tmp/hello.sock").with_service("hello");
    let hello = rsbinder::connect_async::<dyn IHelloAsync<Tokio>>(uri).await?;
    println!("{}", hello.echo("hi").await?);
    Ok::<_, Box<dyn std::error::Error>>(())
})?;
}

The async-over-RPC bridge shown here (new_async_binder + the async proxy over a real Unix-socket session, multi-thread runtime) is verified end-to-end by tests/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-level RpcSession API directly; serve / connect are a thin wrapper over exactly that path.

Switching transports

To run the exact same async service over kernel binder instead, swap only the Uri — the service impl is unchanged. Keep the multi-threaded runtime: run() parks the calling thread in the binder pool, which a current-thread runtime's owner cannot afford (see Advanced: a current-thread runtime) — use spawn() there.

#![allow(unused)]
fn main() {
rsbinder::serve(Uri::kernel())?                    // instead of Endpoint::unix("/tmp/hello.sock")
    .add("hello", BnHello::new_async_binder(IHelloService {}, rt))?  // the `rt` bound above
    .run()?;                                       // joins the process-wide binder pool
}

Be aware that the two transports' trust boundaries differ: see Security & Authorization — @EnforcePermission denies over RPC, and get_calling_uid() is the kernel-vouched peer uid on Unix RPC (fail-closed on uid-less transports). The URI makes the transport swap easy; it does not make the security models identical.

Implementing an Async Service

An async service struct implements the generated IMyServiceAsyncService trait using the #[async_trait] attribute macro. Compare this with the sync version side by side.

Sync service

#![allow(unused)]
fn main() {
use rsbinder::*;

#[derive(Default)]
struct MyService;

impl Interface for MyService {}

impl IMyService::IMyService for MyService {
    fn echo(&self, input: &str) -> rsbinder::BinderResult<String> {
        Ok(input.to_owned())
    }

    fn RepeatInt(&self, token: i32) -> rsbinder::BinderResult<i32> {
        Ok(token)
    }
}
}

Async service

#![allow(unused)]
fn main() {
use async_trait::async_trait;
use rsbinder::*;

#[derive(Default)]
struct MyAsyncService;

impl Interface for MyAsyncService {}

#[async_trait]
impl IMyService::IMyServiceAsyncService for MyAsyncService {
    async fn echo(&self, input: &str) -> rsbinder::BinderResult<String> {
        // You can use .await on async operations here.
        Ok(input.to_owned())
    }

    async fn RepeatInt(&self, token: i32) -> rsbinder::BinderResult<i32> {
        Ok(token)
    }
}
}

The differences are minimal:

  1. Add use async_trait::async_trait; and annotate the impl block with #[async_trait].
  2. Implement IMyServiceAsyncService instead of IMyService.
  3. Prefix each method with async.
  4. When creating the binder, use BnXxx::new_async_binder(impl, rt()) instead of BnXxx::new_binder(impl).

Inside async methods you can .await any future -- call other async services, perform async I/O, use tokio::time::sleep, and so on.

Calling Other Services Asynchronously

When an async service needs to call another Binder service, the proxy it receives is synchronous by default. Use into_async::<Tokio>() to convert it into an async proxy so you can .await the result.

Async version

Based on tests/src/bin/test_service_async.rs:

#![allow(unused)]
fn main() {
async fn VerifyName(
    &self,
    service: &rsbinder::Strong<dyn INamedCallback::INamedCallback>,
    name: &str,
) -> rsbinder::BinderResult<bool> {
    service
        .clone()
        .into_async::<Tokio>()
        .GetName()
        .await
        .map(|found_name| found_name == name)
}
}

Sync version for comparison

From tests/src/bin/test_service.rs:

#![allow(unused)]
fn main() {
fn VerifyName(
    &self,
    service: &rsbinder::Strong<dyn INamedCallback::INamedCallback>,
    name: &str,
) -> std::result::Result<bool, rsbinder::Status> {
    service.GetName().map(|found_name| found_name == name)
}
}

The key difference is:

  • Sync: Call service.GetName() directly.
  • Async: Clone the service reference, convert with .into_async::<Tokio>(), call .GetName(), and .await the result.

The .clone() is necessary because into_async consumes the Strong reference. This is a cheap reference-count increment, not a deep copy.

Cancellation and Nesting

Two properties of the async proxy surprise people who arrive from reactor-based async. Neither is going to change: the first follows from the binder wire having no cancel message, the second from how the kernel driver prevents deadlock.

Dropping a future does not cancel the call

A generated proxy method is not wrapped in an async block. It issues the transaction when you call it, before the returned future is ever polled. So losing a timeout or a select! race — or dropping the future without polling it once — discards the reply, not the call:

#![allow(unused)]
fn main() {
// The remote side runs `transfer` either way. Only the reply is thrown away.
let _ = tokio::time::timeout(Duration::from_millis(50), svc.transfer(amount)).await;
}

Wrapping a non-idempotent call in a timeout and treating expiry as "it did not happen" is therefore wrong. The blocking thread stays occupied until the reply arrives, so repeated timeouts against an unresponsive server fill the spawn_blocking pool (512 threads by default). Cap it with Builder::max_blocking_threads, and bound the wait at the source: over RPC, the session timeout (RpcSession::set_timeout or ClientOptions::timeout, RpcServer::set_reply_timeout on a server), whose expiry ends the whole session — see Timeouts. Kernel binder has no such bound.

A call made inside a handler runs inline

While a thread is handling a transaction, an async proxy call on that thread executes at the call site, synchronously, before the first poll. This is deliberate: the kernel's deadlock prevention needs the nested outbound call to come from the thread that is holding the inbound one, and RPC needs the same thread to keep its session pin.

The consequence is that the usual combinators do nothing there:

#![allow(unused)]
fn main() {
// Inside a handler: expires never — the call has already completed.
let r = tokio::time::timeout(d, other.call()).await;

// Inside a handler: sequential, not concurrent.
let (a, b) = tokio::join!(first.call(), second.call());
}

To get real concurrency, leave the transaction context with tokio::spawn and call from there. That task's calls are no longer nested, so they also lose the deadlock avoidance — do not call back into a service that is currently blocked waiting on you.

Creating Nested Async Binders

An async service can create and return new async binders, for instance when implementing a factory-style method. Pass the same rt() helper:

#![allow(unused)]
fn main() {
async fn GetOtherTestService(
    &self,
    name: &str,
) -> rsbinder::BinderResult<rsbinder::Strong<dyn INamedCallback::INamedCallback>> {
    let mut service_map = self.service_map.lock().unwrap();
    let other_service = service_map.entry(name.into()).or_insert_with(|| {
        let named_callback = NamedCallback(name.into());
        INamedCallback::BnNamedCallback::new_async_binder(named_callback, rt())
    });
    Ok(other_service.to_owned())
}
}

This pattern is taken directly from the test suite. Note that new_async_binder can be called from any async context as long as a TokioRuntime is provided.

Registering Multiple Async Services

A single process can host multiple async services. Register each one before yielding to the runtime:

#![allow(unused)]
fn main() {
runtime.block_on(async {
    let service = BnTestService::new_async_binder(
        TestService::default(), rt(),
    );
    hub::add_service(service_name, &service)
        .expect("Could not register service");

    let versioned_service = BnFooInterface::new_async_binder(
        FooInterface, rt(),
    );
    hub::add_service(versioned_service_name, &versioned_service)
        .expect("Could not register service");

    let nested_service = INestedService::BnNestedService::new_async_binder(
        NestedService, rt(),
    );
    hub::add_service(nested_service_name, &nested_service)
        .expect("Could not register service");

    // All services are now registered. Yield to the runtime.
    std::future::pending().await
})
}

All services share the same Tokio runtime and the same Binder thread pool. Incoming Binder transactions are dispatched to the correct service automatically.

Advanced: BoxFuture Pattern for Macros

When using declarative macros (macro_rules!) to reduce boilerplate in an #[async_trait] impl block, there is a subtlety: async_trait transforms async fn into functions returning a pinned boxed future, but it does not apply this transformation to functions produced by macro expansion. The workaround is to return a BoxFuture manually.

This is the pattern used in the rsbinder test suite:

#![allow(unused)]
fn main() {
type BoxFuture<'a, T> =
    std::pin::Pin<Box<dyn std::future::Future<Output = T> + Send + 'a>>;

macro_rules! impl_repeat {
    ($repeat_name:ident, $type:ty) => {
        fn $repeat_name<'a, 'b>(
            &'a self,
            token: $type,
        ) -> BoxFuture<'b, rsbinder::BinderResult<$type>>
        where
            'a: 'b,
            Self: 'b,
        {
            Box::pin(async move { Ok(token) })
        }
    };
}

macro_rules! impl_reverse {
    ($reverse_name:ident, $type:ty) => {
        fn $reverse_name<'a, 'b, 'c, 'd>(
            &'a self,
            input: &'b [$type],
            repeated: &'c mut Vec<$type>,
        ) -> BoxFuture<'d, rsbinder::BinderResult<Vec<$type>>>
        where
            'a: 'd,
            'b: 'd,
            'c: 'd,
            Self: 'd,
        {
            Box::pin(async move {
                repeated.clear();
                repeated.extend_from_slice(input);
                Ok(input.iter().rev().cloned().collect())
            })
        }
    };
}
}

These macros can then be used inside the #[async_trait] impl block alongside regular async fn methods:

#![allow(unused)]
fn main() {
#[async_trait]
impl ITestService::ITestServiceAsyncService for TestService {
    impl_repeat! {RepeatInt, i32}
    impl_reverse! {ReverseInt, i32}

    async fn RepeatString(&self, input: &str) -> rsbinder::BinderResult<String> {
        Ok(input.into())
    }

    // ... other methods
}
}

The lifetime annotations ('a: 'b, Self: 'b) are necessary to satisfy the borrow checker when the future captures &self and method arguments. This is an advanced pattern -- you will only need it when combining declarative macros with async trait implementations.

Compare with the sync macro version, which is simpler because no future is involved:

#![allow(unused)]
fn main() {
macro_rules! impl_repeat {
    ($repeat_name:ident, $type:ty) => {
        fn $repeat_name(
            &self, token: $type,
        ) -> std::result::Result<$type, rsbinder::Status> {
            Ok(token)
        }
    };
}
}

Tips

  • Prefer new_multi_thread(). new_current_thread() is correct only while its owning thread stays parked in Runtime::block_on; park it elsewhere and nothing runs that runtime — no error, just a handler that never returns the moment it awaits a timer, IO, or a task it spawned there. An RPC server that calls run() parks it elsewhere by definition. A current-thread handle also rules out reaching a second local service through a sync handle from inside a handler.
  • Never block inside an async method. A CPU-bound or blocking call belongs in tokio::task::spawn_blocking, and a call to another binder service belongs behind into_async::<Tokio>() — a blocking proxy call from an async method stalls the executor thread.
  • Budget threads in both directions. Outbound, each async call in flight holds one blocking-pool thread until the reply lands; that thread talks to the driver but is not a looper, so it does not come out of your max_threads — it spends one of the callee's. Inbound, each handler holds one of your binder threads for the whole block_on, so awaiting slow IO inside a handler drains the pool at max_threads and nothing else gets served. Builder::max_blocking_threads caps the outbound half.
  • Sync and async services coexist in one process. Register some with new_binder and others with new_async_binder; they share the same binder thread pool.

Summary

Async services in rsbinder follow the same structure as sync services, with a few additional steps:

  1. Build a multi-threaded Tokio runtime — #[tokio::main] is one.
  2. Implement IMyServiceAsyncService with #[async_trait] instead of IMyService.
  3. Create binders with BnXxx::new_async_binder(impl, TokioRuntime(Handle::current())).
  4. Register them with serve(uri).add(name, &svc) and start with spawn(), not run().
  5. Keep the process alive with std::future::pending().await.
  6. Use into_async::<Tokio>() when calling other Binder services from async code — including a local service of your own, where the sync handle would re-enter block_on.

Because async lives in the generated stubs, the same async service runs over either transport — add the new_async_binder result to rsbinder::serve(uri) and look it up with connect. Nothing in the recipe changes but the Uri — an RPC endpoint additionally needs rsbinder's rpc feature, which is not on by default.

For complete working examples, see tests/src/bin/test_service_async.rs (kernel binder) and tests/tests/rpc_async.rs (RPC) in the rsbinder repository.

Callbacks and Interfaces

Binder IPC is not limited to one-way requests from client to service. Through callback interfaces, a client can pass a Binder object to a service, and the service can call methods on that object. This enables bidirectional communication across process boundaries without requiring the client to register itself as a separate service.

Defining a Callback Interface

A callback interface is a regular AIDL interface. The only difference is in how it is used: instead of being registered with the service manager, it is created by one process and passed to another through a method call.

Here is a minimal callback interface from the rsbinder test suite (INamedCallback.aidl):

package android.aidl.tests;

interface INamedCallback {
    String GetName();
}

This interface defines a single method that returns a string. A service can accept objects implementing this interface, store them, and call GetName later -- regardless of whether the callback lives in the same process or a different one.

Implementing a Callback

Implementing a callback follows the same pattern as implementing any Binder service. Define a struct, implement the rsbinder::Interface trait, and implement the generated AIDL trait:

#![allow(unused)]
fn main() {
struct NamedCallback(String);

impl rsbinder::Interface for NamedCallback {}

impl INamedCallback::INamedCallback for NamedCallback {
    fn GetName(&self) -> std::result::Result<String, Status> {
        Ok(self.0.clone())
    }
}
}

This implementation is identical in structure to a top-level service -- the only difference is that this object will be passed to another service rather than registered in the service manager.

Service-Side Callback Management

A service that works with callbacks typically needs to create, store, and invoke them. The following pattern uses a HashMap to cache callbacks by name:

#![allow(unused)]
fn main() {
#[derive(Default)]
struct TestService {
    service_map: Mutex<HashMap<String, rsbinder::Strong<dyn INamedCallback::INamedCallback>>>,
}

impl Interface for TestService {}

impl ITestService::ITestService for TestService {
    fn GetOtherTestService(
        &self,
        name: &str,
    ) -> std::result::Result<rsbinder::Strong<dyn INamedCallback::INamedCallback>, rsbinder::Status>
    {
        let mut service_map = self.service_map.lock().unwrap();
        let other_service = service_map.entry(name.into()).or_insert_with(|| {
            let named_callback = NamedCallback(name.into());
            INamedCallback::BnNamedCallback::new_binder(named_callback)
        });
        Ok(other_service.to_owned())
    }
    // ...
}
}

Key points:

  • BnNamedCallback::new_binder() wraps the struct in a Binder node so it can cross process boundaries. The Bn prefix stands for "Binder native" (server-side stub).
  • Strong<dyn INamedCallback::INamedCallback> is a strong reference to a Binder object, equivalent to Android's sp<INamedCallback>.
  • Mutex<HashMap<...>> protects the map because Binder calls can arrive on different threads.

Accepting and Invoking Callbacks

A service can also accept callbacks from the client and invoke methods on them. The VerifyName method below receives a callback and calls its GetName method:

#![allow(unused)]
fn main() {
fn VerifyName(
    &self,
    service: &rsbinder::Strong<dyn INamedCallback::INamedCallback>,
    name: &str,
) -> std::result::Result<bool, rsbinder::Status> {
    service.GetName().map(|found_name| found_name == name)
}
}

When the client and service are in different processes, calling service.GetName() triggers a Binder transaction back to the client process. This is completely transparent to the service code -- the proxy handles all the marshalling.

Client-Side Usage

From the client side, working with callbacks is straightforward. You request a callback from the service, call methods on it, and pass it back to the service for verification:

#![allow(unused)]
fn main() {
let service = get_test_service();

// Request a callback from the service
let got = service
    .GetOtherTestService("Smythe")
    .expect("error calling GetOtherTestService");

// Call a method on the callback
assert_eq!(got.GetName().as_ref().map(String::as_ref), Ok("Smythe"));

// Pass the callback back to the service for verification
assert_eq!(service.VerifyName(&got, "Smythe"), Ok(true));
}

Even though the NamedCallback object was created inside the service process, the client can call GetName() on it through Binder IPC. The generated proxy (BpNamedCallback) handles serialization and deserialization automatically.

Callback Arrays

Services can return and accept arrays of callback interfaces. The GetInterfaceArray method creates a callback for each name in the input and returns them as a Vec. On the client side:

#![allow(unused)]
fn main() {
let names = vec!["Fizz".into(), "Buzz".into()];
let service = get_test_service();

let got = service
    .GetInterfaceArray(&names)
    .expect("error calling GetInterfaceArray");

// Each callback has the correct name
assert_eq!(
    got.iter()
        .map(|s| s.GetName())
        .collect::<std::result::Result<Vec<_>, _>>(),
    Ok(names.clone())
);

// Verify all names in a single call
assert_eq!(
    service.VerifyNamesWithInterfaceArray(&got, &names),
    Ok(true)
);
}

Nullable Arrays

Callback arrays can also be nullable, where both the array itself and individual elements may be absent:

#![allow(unused)]
fn main() {
let names = vec![Some("Fizz".into()), None, Some("Buzz".into())];
let got = service
    .GetNullableInterfaceArray(Some(&names))
    .expect("error calling GetNullableInterfaceArray");
}

In this case, the service returns Option<Vec<Option<Strong<dyn INamedCallback::INamedCallback>>>> -- an optional array where each element is itself optional. The None entries in the input produce None entries in the output.

Passing Raw IBinder Objects

You can also pass raw IBinder objects through Binder transactions without committing to a specific interface type. The AIDL definitions use the IBinder type directly:

void TakesAnIBinder(in IBinder input);
void TakesANullableIBinder(in @nullable IBinder input);
void TakesAnIBinderList(in List<IBinder> input);
void TakesANullableIBinderList(in @nullable List<IBinder> input);

In Rust, IBinder maps to SIBinder (a strong Binder reference). You can obtain an SIBinder from any typed interface using the as_binder() method:

#![allow(unused)]
fn main() {
let service = get_test_service();

// Pass the service's own binder reference
let result = service.TakesAnIBinder(&service.as_binder());
assert!(result.is_ok());

// Pass a list of binder references
let result = service.TakesAnIBinderList(&[service.as_binder()]);
assert!(result.is_ok());

// Nullable binder -- pass None
let result = service.TakesANullableIBinder(None);
assert!(result.is_ok());

// Nullable list with mixed Some/None entries
let result = service.TakesANullableIBinderList(
    Some(&[Some(service.as_binder()), None])
);
assert!(result.is_ok());
}

Nested Interfaces

AIDL allows you to define interfaces, parcelables, and enums nested inside another interface. This is useful when a callback type is logically scoped to a single service. Here is the INestedService definition from the test suite:

interface INestedService {
    @RustDerive(PartialEq=true)
    parcelable Result {
        ParcelableWithNested.Status status = ParcelableWithNested.Status.OK;
    }

    Result flipStatus(in ParcelableWithNested p);

    interface ICallback {
        void done(ParcelableWithNested.Status status);
    }
    void flipStatusWithCallback(ParcelableWithNested.Status status, ICallback cb);
}

Implementing a Nested Callback

In the generated Rust code, nested types are accessed through the parent module's namespace:

#![allow(unused)]
fn main() {
#[derive(Debug, Default)]
struct Callback {
    received: Arc<Mutex<Option<ParcelableWithNested::Status::Status>>>,
}

impl Interface for Callback {}

impl INestedService::ICallback::ICallback for Callback {
    fn done(
        &self,
        st: ParcelableWithNested::Status::Status,
    ) -> std::result::Result<(), Status> {
        *self.received.lock().unwrap() = Some(st);
        Ok(())
    }
}
}

Using a Nested Callback

To create and pass a nested callback to the service:

#![allow(unused)]
fn main() {
let service: rsbinder::Strong<dyn INestedService::INestedService> = hub::wait_for_interface(
    <INestedService::BpNestedService as INestedService::INestedService>::descriptor(),
)
.expect("did not get binder service");

let received = Arc::new(Mutex::new(None));

// Create the callback binder
let cb = INestedService::ICallback::BnCallback::new_binder(Callback {
    received: Arc::clone(&received),
});

// Pass NOT_OK to the service; it should flip it to OK via the callback
let ret = service.flipStatusWithCallback(
    ParcelableWithNested::Status::Status::NOT_OK,
    &cb,
);
assert_eq!(ret, Ok(()));

// Verify the callback was invoked with the flipped status
let received = received.lock().unwrap();
assert_eq!(*received, Some(ParcelableWithNested::Status::Status::OK));
}

The key detail is the fully-qualified path for the nested callback's Binder node: INestedService::ICallback::BnCallback. This follows the Rust module hierarchy generated from the AIDL nesting structure.

done(..) arrives as an inbound transaction into the client, so a client that passes a callback needs its binder thread pool running.

Service-Side Nested Callback Handling

On the service side, the nested callback is received as a typed strong reference and can be invoked directly:

#![allow(unused)]
fn main() {
impl INestedService::INestedService for NestedService {
    fn flipStatusWithCallback(
        &self,
        st: ParcelableWithNested::Status::Status,
        cb: &rsbinder::Strong<dyn INestedService::ICallback::ICallback>,
    ) -> std::result::Result<(), Status> {
        if st == ParcelableWithNested::Status::Status::OK {
            cb.done(ParcelableWithNested::Status::Status::NOT_OK)
        } else {
            cb.done(ParcelableWithNested::Status::Status::OK)
        }
    }
}
}

The service flips the status and calls done on the callback. If the callback lives in a different process, this triggers a Binder transaction back to the caller.

Death Recipients

When a client holds a reference to a remote Binder object, it may need to know if the remote process dies. In rsbinder, you implement the DeathRecipient trait and register it with a Binder reference.

Implementing a Death Recipient

#![allow(unused)]
fn main() {
use rsbinder::*;
use std::sync::{Arc, Mutex};
use std::fs::File;
use std::io::Write;

struct MyDeathRecipient {
    write_file: Mutex<File>,
}

impl DeathRecipient for MyDeathRecipient {
    fn binder_died(&self, _who: &WIBinder) {
        let mut writer = self.write_file.lock().unwrap();
        writer.write_all(b"binder_died\n").unwrap();
    }
}
}

The binder_died method is called when the remote process hosting the Binder object terminates. The _who parameter is a WIBinder (weak Binder reference) identifying which Binder object died.

Registering and Unregistering

link_to_death_arc takes your Arc directly and is what you normally want:

#![allow(unused)]
fn main() {
let recipient = Arc::new(MyDeathRecipient {
    write_file: Mutex::new(write_file),
});

service.link_to_death_arc(&recipient)?;
// ... later
service.unlink_to_death_arc(&recipient)?;
}

The link holds only a weak reference, so it never keeps the recipient alive: drop your last Arc and the notification silently stops. Keep recipient alive for as long as you want to hear about the death.

link_to_death / unlink_to_death are the lower-level pair, taking a Weak<dyn DeathRecipient> you build yourself (Arc::downgrade(&(recipient.clone() as Arc<dyn DeathRecipient>))) — use them when the recipient is already type-erased.

As a future

In async code, rsbinder::death_signal replaces the whole DeathRecipient implementation with one select! arm (the tokio feature):

#![allow(unused)]
fn main() {
let died = rsbinder::death_signal(&service.as_binder())?;

tokio::select! {
    result = service_call() => result?,
    _ = died => return Err("the service died mid-call".into()),
}
}

select! is tokio/macros, which rsbinder's tokio feature does not enable; declare it on your own tokio dependency.

The signal holds the binder and the recipient itself, so there is no Arc or handle for you to keep alive, and dropping it unregisters the notification. A binder that is already dead is not an error — the future is simply complete on arrival, which is the honest answer to "tell me when this dies". The two conditions below still apply: a local binder is InvalidOperation, as with link_to_death, and without a running thread pool the future never completes.

Two conditions are easy to miss:

  • Death notification works only for a remote binder. Linking to a local one returns an error; there is no other process to outlive.
  • binder_died arrives as an inbound transaction, so the linking process needs its thread pool running, even if it otherwise only makes outbound calls.
  • Over RPC the same need is checked when you link: a client session that nothing reads — no incoming connections and no serve loop — would never see its connection drop, so link_to_death (and death_signal) return InvalidOperation there, as AOSP does. Open incoming connections (ClientOptions::incoming_connections) or start RpcSession::spawn_serve first; see RPC Transport.

Tips

  • Equality is binder identity. Two Strong<dyn T> compare equal when they name the same binder object, so a service can tell whether two callbacks arriving by different routes are in fact the same one — which is how you de-duplicate a registration list.

  • A callback is an inbound path into your process. Whichever side hands out the callback is the side that then serves transactions on it, and needs a thread pool and Mutex-protected state to do so.

  • as_binder() drops down to SIBinder when you need IBinder-typed parameters or binder-level calls like ping_binder.

ParcelFileDescriptor

Binder IPC typically transfers structured data -- integers, strings, parcelables -- but sometimes you need to pass a file descriptor from one process to another. The ParcelFileDescriptor type makes this possible by wrapping an OwnedFd so that it can be serialized into a Binder Parcel, sent across process boundaries, and deserialized on the other side.

Common use cases include sending pipe endpoints to a service so it can stream data back, sharing access to an open file or socket, and implementing dump() for diagnostic output.

Creating a ParcelFileDescriptor

ParcelFileDescriptor::new() accepts any type that implements Into<OwnedFd>, including std::fs::File, OwnedFd, and the file descriptors returned by rustix::pipe::pipe().

#![allow(unused)]
fn main() {
use std::fs::File;

// From an existing File
let file = File::open("/dev/null").unwrap();
let pfd = rsbinder::ParcelFileDescriptor::new(file);

// A pipe, both ends already wrapped (AOSP `createPipe`). Both are
// O_CLOEXEC.
let (read_pfd, write_pfd) = rsbinder::ParcelFileDescriptor::pipe().unwrap();
}

Since 0.10.0 ParcelFileDescriptor also implements From<std::fs::File> and From<OwnedFd>, so the common concrete cases work with plain conversions — let pfd: ParcelFileDescriptor = file.into();. The generic new() covers any other Into<OwnedFd> source.

Sending a File Descriptor to a Service

A typical pattern is to create a pipe, wrap one end in a ParcelFileDescriptor, send it to a service method, and then read from or write to the other end locally.

The following example is based on the test_parcel_file_descriptor test in the rsbinder test suite:

#![allow(unused)]
fn main() {
use std::io::{Read, Write};

let (read_end, write_pfd) = rsbinder::ParcelFileDescriptor::pipe()?;

// Send the write end to the service; it returns a (duplicated) copy
let result_pfd = service.RepeatParcelFileDescriptor(&write_pfd)?;

// Write through the returned file descriptor
(&result_pfd).write_all(b"Hello")?;

// Read from the original pipe's read end
let mut buf = [0u8; 5];
(&read_end).read_exact(&mut buf)?;
assert_eq!(&buf, b"Hello");
}

Because the service duplicates the descriptor before returning it (see the next section), both the caller and the service hold independent handles to the same underlying pipe.

Duplicating File Descriptors in a Service

When a service receives a ParcelFileDescriptor, it usually needs to duplicate the descriptor before returning it or storing it. This avoids ownership conflicts and ensures each side can close its handle independently.

Since 0.10.0 the type has a built-in ParcelFileDescriptor::try_clone() that duplicates the underlying descriptor with fcntl(F_DUPFD_CLOEXEC) (the duplicate is always close-on-exec, matching AOSP ParcelFileDescriptor::dup), so no hand-rolled dup helper is needed. A service method that repeats a file descriptor back to the caller is then straightforward:

#![allow(unused)]
fn main() {
use rsbinder::ParcelFileDescriptor;

fn RepeatParcelFileDescriptor(
    &self,
    read: &ParcelFileDescriptor,
) -> rsbinder::BinderResult<ParcelFileDescriptor> {
    Ok(read.try_clone()?)
}
}

Working with File Descriptor Arrays

AIDL interfaces can accept and return arrays of ParcelFileDescriptor. The pattern for reversing an array -- a common test case -- illustrates how to combine try_clone() with iterator combinators:

#![allow(unused)]
fn main() {
fn ReverseParcelFileDescriptorArray(
    &self,
    input: &[ParcelFileDescriptor],
    repeated: &mut Vec<Option<ParcelFileDescriptor>>,
) -> rsbinder::BinderResult<Vec<ParcelFileDescriptor>> {
    repeated.clear();
    for fd in input {
        repeated.push(Some(fd.try_clone()?));
    }
    input.iter().rev().map(|fd| Ok(fd.try_clone()?)).collect()
}
}

The repeated output parameter receives a copy of the input in the original order, while the return value contains the input in reverse order. Every descriptor is duplicated so that each Vec owns its own set of file handles.

Reading and Writing

ParcelFileDescriptor implements Read and Write — both by value and by reference, as std::fs::File does — so a descriptor you hold or one a service returned is used directly:

#![allow(unused)]
fn main() {
use std::io::{Read, Write};

let (read_end, write_end) = rsbinder::ParcelFileDescriptor::pipe()?;

// Borrowed: the descriptor stays yours, e.g. to send in a parcel.
(&write_end).write_all(b"payload")?;

let mut buf = [0u8; 7];
(&read_end).read_exact(&mut buf)?;
}

They mean what File's do: reading at end of file is Ok(0) — for a pipe, every writing end is gone — and writing into a pipe whose reader has closed is BrokenPipe. flush() is a no-op, since the only buffer involved is the kernel's.

A pipe holds about 64 KB. Writing more than that blocks until someone drains it, so the usual shape is: create the pipe, send the read end, then fill it — from another thread if the same process is doing both.

For tokio, convert once rather than per call:

#![allow(unused)]
fn main() {
use std::os::fd::OwnedFd;

let file = std::fs::File::from(OwnedFd::from(read_end));
let mut async_file = tokio::fs::File::from_std(file);
}

rsbinder does not wrap that: its tokio feature deliberately does not pull tokio/fs.

File Descriptors over RPC

ParcelFileDescriptor also crosses the socket-based RPC transport (the rpc Cargo feature). Unlike kernel binder, FD passing over RPC is opt-in on both ends and fixed per session: the server declares the modes it accepts with RpcServer::set_supported_fd_modes, and the client requests one before connecting with the fd_mode knob on the RpcClientConfig builder (the fd_mode connect option of the entry API). On the android-14+ wire the mode rides the connection header, as in AOSP; an android-13 (v0) session passes no fds. On the r34 wire, between two rsbinder peers only, RpcSession::negotiate_fd_transport agrees the mode after connecting. Descriptors then travel out-of-band via SCM_RIGHTS, which requires a Unix-domain socket. Service and client code using ParcelFileDescriptor is otherwise unchanged. See RPC Transport for details.

Tips

  • Each side owns its own descriptor. Serializing a ParcelFileDescriptor makes the kernel duplicate the fd for the receiver, so the two handles are independent and close order does not matter.

  • ParcelFileDescriptor is not Clone — it wraps an OwnedFd, which owns the descriptor. Use try_clone(), and use it before storing or re-returning a descriptor you received.

  • try_clone() can fail when the process is out of file descriptors. Propagate it with ? rather than unwrap(); a StatusCode converts into Status automatically.

Shared Memory

Binder transactions copy their payload through the kernel, and a single transaction is capped at the binder buffer size (1 MiB per process by default). For frames, audio chunks, or any buffer you would rather not copy per call, Android services share memory instead: the buffer is an anonymous shared region, only its file descriptor crosses the binder, and both processes map the same pages.

rsbinder's shared_memory module provides that pattern on Linux, Android and macOS, wire-compatible with Android's own types:

rsbinderAndroid equivalenton the wire
SharedMemoryandroid.os.SharedMemory, NDK ASharedMemoryone ParcelFileDescriptor
MemoryHeapBase / BnMemoryHeap / BpMemoryHeaplibbinder IMemoryHeap (android.utils.IMemoryHeap)handwritten AOSP transaction
MemoryBase / BnMemory / BpMemorylibbinder IMemory (android.utils.IMemory)handwritten AOSP transaction
MemoryDealer / Allocationlibbinder MemoryDealerIMemory windows on one heap

The full example lives in example-hello/src/bin/shm_service.rs and shm_client.rs; this page walks through it.

Which transports can carry a region?

A shared region is a file descriptor, so it travels wherever an fd can:

  • Kernel binder (Linux with binderfs, Android) — always.
  • Unix-socket RPC — when both sides opt into fd passing (FileDescriptorTransportMode::Unix, see below).
  • TCP / vsock / TLS RPC — never through the entry API, which refuses the Unix fd mode on these transports; writing a region into such a parcel fails with StatusCode::FdsNotAllowed, exactly like a plain ParcelFileDescriptor.

0. One large value: Parcel::write_blob

Before reaching for a region of your own, note that a byte payload has a ready-made form. Parcel::write_blob carries it inline when it is at most 16 KB and through a shared region when it is larger, tagging the wire so the reader knows which it got. The bytes are Java's Parcel.writeBlob: an int32 length, then what C++ Parcel::writeBlob writes. A C++ peer reads the length itself and passes it to Parcel::readBlob(len, …), as the JNI layer under Java does:

#![allow(unused)]
fn main() {
parcel.write_blob(&payload, false)?;      // false: the reader may not write it

let blob = parcel.read_blob()?;
let bytes = blob.to_vec()?;               // whichever form it took
}

Two properties make it the right default for "a big lump of bytes":

  • The transport decides, not your code. Where fds cannot travel — vsock, TLS, or a data-only parcel — the payload simply goes inline, as AOSP does when mAllowFds is false. There is nothing to configure and no error to handle. Parcel::allow_fds() answers the same question if you want to know in advance.
  • An immutable blob is sealed (F_SEAL_FUTURE_WRITE), so the reader maps a snapshot it cannot modify. Pass true to leave the reader's copy writable (BLOB_ASHMEM_MUTABLE). The writer keeps no handle on the region, so nothing the reader writes comes back; for a buffer both sides share, use a region of your own (below).

Reach for the sections below when you need the region itself: a window into a larger heap, a lifetime longer than one call, or many buffers out of one allocation.

1. One region: SharedMemory + ParcelFileDescriptor

This is the simplest and the most portable form — it is what Android apps do with SharedMemory. In AIDL the region is just a ParcelFileDescriptor:

package shm;

interface IShm {
    ParcelFileDescriptor getRegion();
    void regionWritten(int offset, int len);   // "data is ready" signal
    IBinder nextFrame(int seq);                 // see section 2
}

Service side

#![allow(unused)]
fn main() {
use rsbinder::shared_memory::SharedMemory;

struct ShmService {
    region: SharedMemory,
    // …
}

impl ShmService {
    fn new() -> rsbinder::Result<Self> {
        let region = SharedMemory::create(64 * 1024)?;   // page-rounded
        region.write_at(0, b"hello from shm_service")?;  // through our own mapping
        Ok(Self { region })
    }
}

impl IShm for ShmService {
    fn getRegion(&self) -> rsbinder::BinderResult<ParcelFileDescriptor> {
        // dup the fd for the parcel; the service keeps its mapping
        Ok(self.region.to_parcel_fd()?)
    }

    fn regionWritten(&self, offset: i32, len: i32) -> rsbinder::BinderResult<()> {
        let mut buf = vec![0u8; len as usize];
        self.region.read_at(offset as usize, &mut buf)?;   // the client's bytes
        Ok(())
    }
    // …
}
}

Client side

#![allow(unused)]
fn main() {
use rsbinder::shared_memory::SharedMemory;

let shm: Strong<dyn IShm> = client.get::<dyn IShm>(SERVICE_NAME)?;

let pfd = shm.getRegion()?;
let region = SharedMemory::from_fd(pfd.into())?;   // size recovered from the fd

let mut greeting = [0u8; 22];
region.read_at(0, &mut greeting)?;                  // same physical pages

region.write_at(4096, b"hello back")?;              // visible to the service at once
shm.regionWritten(4096, 10)?;                       // tell it to look
}

SharedMemory::from_fd accepts anything Android would send here: a memfd, a macOS POSIX shm object, or a legacy /dev/ashmem fd (its size is read with ASHMEM_GET_SIZE). A region that was sealed read-only by the owner maps read-only automatically (is_read_only()), and write_at then returns PermissionDenied.

Synchronisation is yours

The library does not serialise access to the pages: a region is, by definition, writable by the other process at any time. Use an ordinary binder call as the hand-off signal (regionWritten above), the way AOSP services do, and prefer the copying accessors read_at / write_at over holding a &[u8] into the region.

2. Many buffers: MemoryDealer + IMemory

Allocating a region per frame means an fd and an mmap per frame on both sides. AOSP's MemoryDealer avoids that: one heap is created and mapped once, and each frame is an (offset, size) window of it — an android.utils.IMemory binder whose reply names the shared heap.

Service side

#![allow(unused)]
fn main() {
use rsbinder::shared_memory::{Allocation, MemoryDealer};

struct ShmService {
    dealer: Arc<MemoryDealer>,
    frames: Mutex<VecDeque<Allocation>>,   // bounded; dropping returns the block
}

fn nextFrame(&self, seq: i32) -> rsbinder::BinderResult<SIBinder> {
    let frame = self.dealer.allocate_page_aligned(4096)?;   // NoMemory when full
    frame.write_at(0, format!("frame #{seq}").as_bytes())?;
    let binder = frame.export();                             // the IMemory binder
    let mut frames = self.frames.lock().unwrap();
    frames.push_back(frame);
    if frames.len() > 4 {
        frames.pop_front();   // Allocation::drop → block back to the dealer
    }
    Ok(binder)
}
}

The allocator is AOSP's SimpleBestFitAllocator: 32-byte granules, best-fit, neighbours coalesce on free, and allocate_page_aligned for buffers that must start on a page. Peers that still hold an exported binder keep reading the window after the Allocation is dropped; only the dealer's bookkeeping changes — so keep a frame alive until the consumer is done with it.

Client side

#![allow(unused)]
fn main() {
use rsbinder::shared_memory::{BpMemory, HeapCache};

let cache = HeapCache::new();        // one per session
for seq in 0..6 {
    let binder = shm.nextFrame(seq)?;
    let frame = BpMemory::new_with_cache(binder, cache.clone());
    let heap = frame.resolve()?;     // GET_MEMORY; HEAP_ID + mmap only the first time
    let mut buf = [0u8; 40];
    frame.read_at(0, &mut buf)?;     // offset is relative to the window
    assert_eq!(cache.len(), 1);      // one mapped heap behind every frame
}
}

Without the cache each BpMemory maps the heap on its own (still correct, just more VMAs); with it, frames from the same dealer resolve to bare offsets.

Because the transactions are the handwritten AOSP layout, the same binders work against a C++ interface_cast<IMemory> client or a C++ MemoryHeapBase server — that interop is part of rsbinder's test gate (example-hello/cpp/run_imemory_interop.sh).

Running the example

Kernel binder (Linux with rsb_hub running, or an Android device):

cargo run -p example-hello --bin shm_service kernel &
cargo run -p example-hello --bin shm_client kernel

Unix-socket RPC (works on macOS too; fd passing needs the rpc feature):

cargo run -p example-hello --features rpc --bin shm_service rpc &
cargo run -p example-hello --features rpc --bin shm_client rpc

Over RPC both ends must opt into fd passing before connecting. Through the entry API it is an option on the connect; on an android-14+ session you built yourself it is RpcClientConfig::fd_mode on the client and RpcServer::set_supported_fd_modes on the server, since the connection header fixes the mode. Only an r34 session negotiates it afterwards, with RpcSession::negotiate_fd_transport:

#![allow(unused)]
fn main() {
// service
rsbinder::serve(&uri)?
    .with(|o| o.fd_modes = Some(vec![FileDescriptorTransportMode::Unix]))
    .add(SERVICE_NAME, BnShm::new_binder(ShmService::new()?))?
    .run()?;

// client
let client = rsbinder::Client::open_with(&uri, |o, _endpoint| {
    o.fd_mode = Some(FileDescriptorTransportMode::Unix);
})?;
}

Both options are rejected on an endpoint that cannot carry fds, so ask endpoint.supports_fd_passing() first when the URI is not fixed (as shm_client.rs does). Without the opt-in, getRegion() fails with FdsNotAllowed.

Expected client output:

shm_client: region 65536 bytes, read-only=false, says "hello from shm_service"
shm_client: frame #0 @ offset 0 (window 0+4096 of a 1048576 byte heap, 1 mapped heap(s))
shm_client: frame #1 @ offset 4096 …       (16384 on a 16 KiB-page macOS)
…
shm_client: frame #5 @ offset 0 …          ← a recycled block

What the kernel enforces

propertyLinux / Android (memfd)macOS (POSIX shm)
size cannot change after creationF_SEAL_SHRINK | F_SEAL_GROWinherent: one ftruncate only
read-only for peers (FLAG_READ_ONLY, seal_read_only)F_SEAL_FUTURE_WRITEthe owner exports an O_RDONLY fd
receiver can verifyF_GET_SEALSfcntl(F_GETFL)

MemoryHeapBase::seals() reports these as the same SEAL_* bits on every platform, and MappedHeap::from_fd_strict rejects a sender whose claims the kernel does not back. One macOS difference to know: sealing read-only after a writable fd was already handed out does not revoke that fd — create the region with FLAG_READ_ONLY when peers must never write.

Streaming

A binder method returns one value. A method that would return a million rows either builds the whole list in memory on both sides — and runs into the transaction size limit long before that — or invents a paging protocol. rsbinder::stream is the third option: the consumer makes an endpoint and hands it to the producer in the one call that opens the stream; the producer writes items into it and can only run as far ahead as the consumer allows.

It is for a sequence in which every item matters. Three neighbours cover what it is not for:

What you haveUseWhy
A sequence of values, all of which must arriversbinder::stream (this chapter)Items cannot be dropped, so a slow consumer has to be able to slow the producer down
A value that changes, where only the current one mattersA oneway callback — see belowStale values may be dropped, so nothing needs slowing down
Bytes — a file, a log, a media streamParcelFileDescriptor::pipeThe kernel already does back-pressure on a pipe
A large buffer both sides read in placeShared memoryNo copy at all

New in 0.12.0. The same two types run over kernel binder and over RPC, on different wires: over kernel binder the items travel on a shared-memory ring the consumer allocates, over RPC as oneway batches paced by credit — see What is on the wire. The RPC side has one requirement on the client.

A minimal stream

The method that starts a stream takes the consumer's endpoint — rsbinder.stream.StreamEndpoint<T>, a parcelable shipped in rsbinder/aidl/stream/ that rsbinder-aidl resolves without a copy of the file — and returns nothing. The type argument is the item type. Everything else travels over the stream itself:

package demo;

import rsbinder.stream.StreamEndpoint;

parcelable LogLine {
    long timestampMs;
    String text;
}

interface ILogService {
    void tail(in StreamEndpoint<LogLine> endpoint, String tag);
}

The service opens a sink on the endpoint and writes from a thread of its own. The endpoint's type argument makes it a Sink<LogLine>. send blocks once the consumer has fallen behind, which is the back-pressure — and the reason the producer does not run on the binder thread that took the call:

#![allow(unused)]
fn main() {
use rsbinder::stream::{Sink, StreamEndpoint};

impl ILogService for LogService {
    fn r#tail(&self, endpoint: &StreamEndpoint<LogLine>, tag: &str) -> rsbinder::BinderResult<()> {
        let mut sink = Sink::open(endpoint)?;
        let lines = self.open(tag);
        std::thread::spawn(move || {
            for line in lines {
                if sink.send(&line).is_err() {
                    return; // cancelled, or the consumer is gone
                }
            }
            let _ = sink.end();
        });
        Ok(())
    }
}
}

The consumer makes a receiver against its peer — a binder in the producer's process, here the service it is about to call — passes the endpoint it gets back, and reads:

#![allow(unused)]
fn main() {
use rsbinder::stream::Receiver;

let (mut lines, endpoint) = Receiver::<LogLine>::new(&service.as_binder())?;
service.r#tail(&endpoint, "net")?;
for line in &mut lines {
    println!("{}", line?.text);
}
}

The peer decides two things: which transport the stream runs on (a kernel proxy makes a ring endpoint, and so does a local object on Linux and Android; an RPC proxy makes a sink-only one unless the consumer opts in to a ring — see A ring over a Unix session — and so does a local object elsewhere) and whom the consumer watches for death. Because the opening call is twoway, a refusal — the transport cannot carry the stream, the ring is larger than the producer accepts, SELinux denies the mapping — comes back as that call's error.

On Android the default SELinux policy grants memfd_file access only on a domain's own memfds, and the ring is the consumer's memfd, which the producer checks, reads and writes. A producer in another domain therefore needs memfd_file { getattr read write map } on the consumer's domain, besides the fd use any descriptor passed between the two needs — a platform service that streams to apps carries allow <service> appdomain:memfd_file { getattr read write map }; in its own .te — or Sink::open fails with EACCES.

When the client is the producer — an upload — the direction of the call flips: the client passes a binder of its own (or a stream::Token made for the purpose), the service makes the receiver against it in the handler and returns the endpoint, and the client opens its sink on the returned value:

StreamEndpoint<Chunk> upload(IBinder producer);
#![allow(unused)]
fn main() {
// Client
let token = rsbinder::stream::Token::new();
let endpoint = service.r#upload(&token.binder())?;
let mut sink = Sink::open(&endpoint)?; // a Sink<Chunk>
// ... keep `token` alive for as long as the stream runs.
}

Either way the endpoint crosses in the reply or the arguments of one twoway call. A service that wants to start a stream from a oneway callback — an event it pushes to the client — has no reply to carry the endpoint, so the consumer needs one more twoway call to hand it over.

Receiver<T> is an Iterator<Item = BinderResult<T>> that ends when the producer does; recv, try_recv and recv_timeout are the same thing one item at a time, differing only in how long they wait for one. Items are encoded with the codec to_bytes uses, so an item may hold neither a binder nor a file descriptor; send refuses one that does.

The item type is checked at compile time

The type argument is where the .aidl states what the items are, and both ends are held to it: the generated method takes a StreamEndpoint<LogLine>, only a Receiver<LogLine> makes one, and Sink::open on it is a Sink<LogLine>. A consumer that reads the stream as some other type, or a service that writes one, does not compile.

That matters most for the mismatches nothing else would catch. The items carry no type tag, only their encoding, so the consumer ends the stream with an error only when decoding happens to notice — an item's bytes running out (NotEnoughData) or left over (BadValue), a null marker where a value is required (UnexpectedNull). A mismatch it does not notice — int read as float, two parcelables with the same fields — decodes as the wrong value.

The type argument is not on the wire: the endpoint is the same bytes whatever it names, so a C++ peer built from the same .aidl (where it is StreamEndpoint<LogLine> too, a class template) interoperates. For the same reason a Rust end may read or write a type of its own that encodes like the declared one; StreamEndpoint::cast::<U>() says so explicitly, and nothing then checks that the two agree. A service handler receives &StreamEndpoint<T>, so it casts a copy: Sink::<U>::open(&endpoint.try_clone()?.cast()). A producer holding borrowed items needs no cast: Sink::<str>::open_borrowed takes a StreamEndpoint<String> and sends &str lines without a String for each.

rsbinder-aidl refuses an array or a List as T (AOSP's aidl accepts either); wrap one in a parcelable.

What is on the wire

StreamEndpoint has two fields, ring and sink, and which one carries the items depends on the transport the two ends share.

Kernel binder: a ring

The endpoint carries a ring: a synchronized Fast Message Queue of bytes (android.hardware.common.fmq.MQDescriptor<byte, SynchronizedReadWrite>, the same type libfmq uses) that the consumer allocates — sealed against shrinking and charged to its own process — and the producer maps. Each item is one record written in place: a 4-byte header (kind bit plus payload length) followed by the item's parcel bytes. A full ring parks the producer on the ring's futex until the consumer reads; an empty ring parks the consumer until the producer writes. Before parking, a blocking call looks at the ring for up to 20 µs (not on a single-core machine, never on an async executor thread, not past a first short round while threads for half the cores already look, and only on every eighth wait of a side whose last 64 looks found nothing), and the consumer copies every record the producer has committed out at once and frees the space with one wake. A look costs CPU while it lasts: it pays where the other end is about to act, and is turned down where items come further apart than the look or more threads look than the cores can run. After the opening call no binder call carries anything in either direction: the end of the stream is the last record, and a cancel is a bit in the ring's EventFlag word. The sink binder is still in the endpoint, but the producer only links to it for death.

The record layout, the end reserve and the three EventFlag bits (NOT_FULL, NOT_EMPTY, CANCEL) are documented in StreamEndpoint.aidl; a C++ peer on libfmq implements against that.

RPC: two oneway interfaces

By default an RPC session carries no shared memory, so the endpoint carries only the consumer's sink (a consumer may opt in to a ring on a Unix session, below), and the stream is two ordinary AIDL interfaces, shipped beside the parcelable in rsbinder/aidl/stream/:

package rsbinder.stream;

interface IStreamSink {                      // implemented by the consumer
    // `source` is an IStreamSource, `credits` the window the producer opens
    // with; sent once, first.
    oneway void onStart(IBinder source, int credits);
    oneway void onBatch(in byte[] items, int count);
    oneway void onEnd(int exception, int serviceSpecific, @nullable String message);
}

interface IStreamSource {                    // implemented by the producer
    // `total` batches granted in all since the stream began — a running
    // total, not an amount to add.
    oneway void request(long total);
    oneway void cancel();
}

This is the reactive-streams contract under binder names: onStart / onBatch / onEnd are onSubscribe / onNext / onComplete-or-onError, and IStreamSource is the Subscription. It differs in one place: reactive-streams' request(n) adds n, and request(total) here states a running total. A oneway call that reports failure has almost always not arrived, but its sender cannot always tell, and an amount sent again would then be counted twice or not at all. A total is counted once whichever happened, which is what lets the two ends agree on how many batches the producer may send.

Every call is oneway. A session keeps oneway calls to one object in order, which is what guarantees the source is known before the first batch and that onEnd cannot overtake the last one. It also means neither end ever waits on the other's threads: granting credit costs the consumer one send, however busy the producer's process is — a send that waits for a free outgoing connection on the session, see Over RPC.

Credit, batches and the two policies

Flow control is set on each side when the stream is made: Receiver::with_policy(&peer, &ReceiverPolicy { .. }) on the consumer and Sink::open_with(&endpoint, &SinkPolicy { .. }) on the producer. Each policy has fields for both paths; the ones for the path not in use are ignored. A ring over a Unix session takes the ring fields and ping; the credit and batch fields do not apply to it. Receiver::new and Sink::open take the defaults.

Policy fieldPathDefaultWhat it sets
ReceiverPolicy::ring_bytesring64 KiBThe ring the consumer allocates — the whole of the flow control (the producer runs ahead by under twice it), and the bound on the largest item
SinkPolicy::max_ring_bytesring4 MiBThe largest ring the producer maps; a bigger one is refused at Sink::open
ReceiverPolicy::ring_useRPC sessionKernelOnlyWhether an RPC peer may get a ring; see A ring over a Unix session
SinkPolicy::max_batch_bytescalls16 KiBThe byte threshold at which a pending batch is sent
SinkPolicy::initial_creditscalls4The window the producer opens with — its ceiling on batches in flight
SinkPolicy::send_timeoutbothNoneHow long one send/send_all/flush/end call (or *_async future, from its first poll) may wait for ring room or credit before it returns TimedOut; the item is not written or queued and the stream stays usable. Some(Duration::ZERO) never waits. It does not bound the RPC session's own send deadline
ReceiverPolicy::credit_windowcalls4The consumer's grant threshold: a grant leaves once half of it is owed
ReceiverPolicy::max_openingcalls4The widest opening window the consumer accepts
SinkPolicy::ping, ReceiverPolicy::pingRPC sessionInheritWhether a wait that hears nothing from the other end pings it; see a peer that goes silent

On the ring the ring's size is everything: the producer waits for room once the ring is full. The consumer copies every record in the ring out at once and frees the ring right away, so the producer can be ahead of what recv has returned by the ring plus the records the consumer has copied out and not yet returned — under twice ring_bytes, and the consumer's copy buffer grows to at most the ring's size, plus a decode buffer kept at the largest item's size. The last END_RESERVE (256) bytes are never given to items, so the end record always has room; the largest item is therefore ring_bytes - END_RESERVE - 4, and a stream of large items needs a larger ring. The memory is allocated up front and charged to the consumer's process. The default of 64 KiB is sixteen pages; the producer's max_ring_bytes protects its own address space against a consumer that describes a ring of any size. Once the ring holds a few of the largest items, a larger ring mainly lets the producer run further ahead; one that holds only one or two makes the two ends take turns.

Over RPC credit is counted in onBatch calls. The producer opens with a window of credit, spends one per batch, and waits when it has none; the consumer grants credit back for the batches it has taken — half a window at a time while more batches are already queued, and everything it owes before it goes to wait.

Only the producer's opening window bounds what is in flight. A grant pays for a batch the consumer has already drained, one for one, so the ceiling stays whatever initial_credits was; credit_window sets the grant threshold — a grant leaves once half of it is owed, or the whole opening window if that is less — not how far the producer may run ahead.

The consumer holds the producer to its credit. The producer states its opening window in onStart, so the consumer knows everything the producer may send: that window, plus what has been granted since. A batch beyond it ends the stream with EX_ILLEGAL_STATE instead of being queued, which is what bounds the memory a producer can make the consumer's process hold. That matters most when the consumer is the service — an upload — and its producer is a client it has no reason to trust.

How wide a window a consumer takes is the consumer's to say: max_opening. A producer that opens wider is refused at the start, with EX_ILLEGAL_ARGUMENT, before any item. The two defaults are the same number, so a producer made with Sink::open is always taken by a consumer made with Receiver::new; a wider window has to be asked for on both sides. The producer hears of a refusal as a cancel — onStart is oneway — and the reason is in the consumer's error and in its log.

The batch size is a threshold, not a cap: an item larger than it still goes out, with the batch it was added to.

When a batch leaves

Batching is an RPC-path matter. On the ring every accepted item is already in the ring when send returns; nothing is queued, and flush returns at once — unless a dropped send_async left a record on the pool, which flush waits for (see Async).

Over RPC two things send a batch, and a clock is neither of them: the pending batch reaching the byte threshold, and a call to flush or end.

  • Items already in hand — a query result, a directory listing. The threshold does the batching and end sends the remainder.
  • Items arriving at their own pace — an event log, a progress feed. Call flush when what has been sent so far is what the consumer should have now, typically after each event or burst. Without it an item waits for the next send to fill the batch, which for an idle producer is not a bounded wait, and the consumer blocked in recv cannot tell that from a producer with nothing to say. Sink::pending() reports how many items are queued.

flush on an empty batch returns at once, and on the ring costs nothing beyond that pool wait, so a producer unsure which path it is on can simply flush after every item.

Ending a stream

end() finishes the stream: on the ring it writes the end record, for which room is always reserved, so it waits for the consumer only for a record a dropped send_async left on the pool (see Async); over RPC it flushes first, and that flush blocks for credit. end_with(&status) does the same and hands the consumer an error — the failure the starting method could have returned, arriving late because that method had already succeeded:

#![allow(unused)]
fn main() {
sink.end_with(&rsbinder::Status::new_service_specific_error(
    QUOTA_EXCEEDED,
    Some("daily quota reached".into()),
))?;
}

On the other side the iterator yields that Status as its last Err, with the service-specific code and message intact, and then ends. Receiver::end_status() has the final status afterwards, including a clean one, and is None until the end has been read: a death notice or terminator that lands while items remain is not yet the end, and those items come out first. On the ring the message is cut to what the end reserve holds (244 bytes).

Call end on every path. A Sink that is dropped without it still ends the stream, but as EX_ILLEGAL_STATE with a message saying how many items were lost — from the consumer's side that is all a producer that vanished can mean. The drop does not wait for the consumer, since a Drop that blocks would hold whatever thread is unwinding: over RPC the items that cannot leave without credit are lost, on the ring a record a dropped send_async future left waiting for room is given up.

Cancelling, and a peer that dies

Receiver::cancel() tells the producer to stop, and dropping the receiver sends it for you. A parked producer is released; its send returns StatusCode::InvalidOperation and Sink::is_canceled() is what tells that apart from a transport problem. From then on every send returns it at once without taking the item, on both paths. end still sends the terminator it was given, without reporting a loss: over RPC the items still queued are dropped. On the ring the producer sees the cancel before its next write, or in the wait it is parked in; over RPC a cancel made before the producer has introduced itself is held and delivered the moment onStart arrives, and the producer sees it at its next send.

Back-pressure means that most of the time neither side has a call in flight, so nothing would fail by itself if the other process went away. Each end therefore watches the other's binder — the producer the endpoint's sink, the consumer the peer it was made against: a producer parked for room or credit gets StatusCode::DeadObject from send, and a consumer blocked in recv gets the same error as the stream's last item. A peer that is not in the producer's process leaves the producer's death unseen, and a local object is not watched at all. Over RPC, a session that ends is a death in the same sense.

Death does not take back what already arrived: the consumer reads every item already in the ring or in a delivered batch before it sees the death, and an end that reached it first — an end record already in the ring, an onEnd already received — is the end the stream reports, not DeadObject. Once the producer knows the consumer is dead, every send returns DeadObject at once without taking the item, on both paths, and so does end.

A peer that goes silent

Over RPC the session has to notice the loss first. It does when a connection fails, when the peer's host stops acknowledging (TCP keepalive — see Timeouts), or when a reply is overdue, and a waiting stream has no reply outstanding: every stream method is oneway. Behind a relay that terminates TCP (adb forward, ssh -L) a peer can vanish with none of the three happening, and the stream would wait for good; vsock has no keepalive either.

So a waiting end pings. When a recv, or a producer's wait for credit, has been woken by nothing for a third of the session's reply deadline, it sends the other end a PING_TRANSACTION — the twoway every binder object answers, so a C++ or NDK peer needs no code for it — and waits again once it is answered. The consumer pings the producer's IStreamSource once onStart has named it, the producer pings the sink. A ring over a Unix session pings the same way (its consumer pings the binder it was made against), as does a producer parked on a full one; a ring over kernel binder never pings, since the kernel reports a death itself. An unanswered ping ends the session through the reply deadline, like any unanswered call, and the stream then ends with DeadObject through its death link: a peer that vanished is noticed within about four thirds of the deadline. A stream whose items flow never pings.

The deadline is the session's: RpcSession::set_timeout or ClientOptions::timeout on a client, RpcServer::set_reply_timeout or ServeOptions::reply_timeout on a server. A session without one — the default — never pings, and PingPolicy::Off in either policy turns it off for that end. The ping is not counted against recv_timeout or send_timeout, so a call whose peer stops answering returns later than its own bound, by up to the deadline. An *_async wait that may ping waits on the blocking pool (see below).

Async

Both ends have tokio counterparts, behind the default tokio feature. Each of them makes its wait from the blocking pool — a futex wait on the ring, a oneway send over RPC — so poll them inside a Tokio runtime context: spawn_blocking panics outside one. (A call made from inside a transaction handler runs on the calling thread and is exempt.) A futex cannot be polled, so on the ring each wait holds one pool thread for as long as it lasts, the same cost as a thread of its own.

The consumer awaits recv_async(). There is no futures::Stream impl — that would tie rsbinder's public API to a futures-core major version — so adapt it where you need one:

#![allow(unused)]
fn main() {
while let Some(line) = lines.recv_async().await {
    handle(line?).await;
}
}

The producer uses send_async, flush_async, end_async and end_with_async from a task. On calls, waiting for the consumer suspends the task instead of parking a thread — except a wait that may ping or has a send_timeout, which holds a pool thread while it lasts, as does a recv_async that may ping: there is no timer to suspend a task against. On the ring every such wait holds a pool thread, as above.

#![allow(unused)]
fn main() {
let mut sink = Sink::open(endpoint)?;
tokio::spawn(async move {
    while let Some(line) = source.next().await {
        if sink.send_async(&line).await.is_err() {
            return;
        }
    }
    let _ = sink.end_async().await;
});
}

send_async encodes the item when it is called rather than when it is first polled, so the future does not borrow the item and is Send whatever the item type is. send_async, flush_async and recv_async are cancel-safe: a future dropped mid-wait loses no credit, and no item without the terminator saying so. A record or batch already handed to the blocking pool still goes out, in its place, and the next call waits for it first; a send that then fails is not forgotten with the future — the next call that sends (flush_async, or a send/send_async that has to wait) returns that failure, or the terminator does, and the terminator counts those items as lost. On the ring, a send_async future dropped while it still waits for such an earlier record has handed nothing over: its item is dropped, and the terminator counts it as lost too. The two terminators are not waits to abandon — a dropped end_async or end_with_async leaves the stream to Drop, which ends it as EX_ILLEGAL_STATE without waiting for the consumer, so whatever was still queued is reported lost.

Over RPC

Each end calls the other from outside any handler — the producer sends batches from a thread of its own, the consumer sends grants and cancel — and each end watches the other for death. Even the producer's onStart, sent from inside the handler of a download, is oneway and so never rides the connection the request came in on. A default RPC session can do none of this toward the client: nothing on the client reads its connection except inside its own calls. The client has to open an incoming connection, which takes the android-13+ profile — see Callbacks outside a handler:

#![allow(unused)]
fn main() {
use rsbinder::entry::{WireProfile, WireVersion};

let uri = rsbinder::Uri::new(rsbinder::Endpoint::unix("/run/demo.sock"))
    .with_wire(WireProfile::Android13Plus(WireVersion::MAX));
let client = rsbinder::Client::open_with(uri, |o, _| o.incoming_connections = Some(1))?;
}

Without one, the client's end refuses with StatusCode::InvalidOperation before any call is made — Receiver::new when the client consumes, Sink::open when it produces — and logs which option is missing. Client::caps() reports TransportCaps::CALLBACKS when the session can carry a stream. A session with incoming connections has to be ended explicitly, with client.session().unwrap().close_session(): its serving threads keep it alive after every handle is dropped.

One more thing to size: the consumer's grants, and both ends' pings, go out on the session's outgoing connections, and each waits for a free one. A client that keeps its only outgoing connection busy with a long twoway call delays its own grants for that long; give such a client a second one with outgoing_connections.

A ring over a Unix session

When both ends are on one host and talk over a Unix socket, the consumer can have the stream run on a ring instead of calls:

#![allow(unused)]
fn main() {
use rsbinder::stream::{Receiver, ReceiverPolicy, RingUse};

let policy = ReceiverPolicy { ring_use: RingUse::AlsoUnixRpc, ..Default::default() };
let (mut rx, endpoint) = Receiver::<LogLine>::with_policy(&service.as_binder(), &policy)?;
if !rx.uses_ring() {
    // The session could not carry one; the stream runs on calls as usual.
}
}

The ring's memfd crosses the socket as an SCM_RIGHTS file descriptor, so the session has to pass fds both ways: the client opens with ClientOptions::fd_mode = Some(FileDescriptorTransportMode::Unix) and the server allows it (ServeOptions::fd_modes or RpcServer::set_supported_fd_modes). It also needs the incoming connection above. A session that cannot pass the fd — vsock, TCP, TLS, no fd mode — gets calls, and a log::debug! line names what was missing; uses_ring tells which one a stream got. A session with no incoming connection refuses the stream with InvalidOperation, opted in or not, as it does on calls. The ring needs Linux or Android.

Across SELinux domains the ring needs what it needs over binder — fd use and memfd_file { getattr read write map } on the consumer's domain, see A minimal stream — plus what the session needs: unix_stream_socket connectto on the server's domain, and for a socket bound to a path, sock_file write on that file's type. An abstract socket (RpcServer::setup_unix_server_abstract) has no file to check.

Which to choose is a matter of item size. Measured on x86-64 over a Unix session, calls with 16 KiB batches were faster for items up to 128 bytes (2.6 times for i32, 1.6 times at 64 bytes, 1.2 times at 128), where the ring's per-record work outweighs a batched call; from 192 bytes on the ring was faster, 1.5 times at 256 bytes, 3 times at a kilobyte and over 4 times at 16 KiB. From 128 bytes on the ring also used less CPU. On an arm64 Android emulator the ring was as fast as calls for i32 and faster above.

The producer has to implement the ring over RPC; rsbinder's does, and nothing changes on its side. A producer written to the RPC-only contract refuses the endpoint, and how that shows depends on the direction: in a download its refusal is the error of the opening call; in an upload the opening call has already returned the endpoint, so the consumer learns of it only from the producer's death or its own recv_timeout. A producer that ignores the ring and sends batches ends the stream with EX_ILLEGAL_STATE.

The items do not cross the socket, so to the session a flowing ring looks quiet. The server's end therefore counts its ring traffic toward the session's idle check (RpcServer::set_idle_timeout); a client has no idle check to feed. A stream whose items keep coming, or come more often than the timeout, keeps the session alive. Each wait on the ring that parks counts too, so a server-side end that keeps waiting with a recv_timeout or send_timeout longer than the ring's spin (20 µs) and shorter than the idle timeout keeps the session alive with no item moving, where on calls it would not. A stalled one whose waits outlast the timeout does not, as on calls, unless it pings.

State, not a sequence: use a oneway callback

A temperature, a progress percentage, a connection state: the consumer wants the current value and a stale one is worthless. That is a different problem, and back-pressure is the wrong tool for it. It exists because items cannot be dropped, so a slow consumer has to be able to stop the producer. When values can be dropped, nothing has to stop anybody — provided the handler that receives them never does slow work:

interface ITemperatureListener {
    oneway void onChanged(float celsius);
}
#![allow(unused)]
fn main() {
struct Listener(tokio::sync::watch::Sender<f32>);

impl ITemperatureListener for Listener {
    fn r#onChanged(&self, celsius: f32) -> rsbinder::BinderResult<()> {
        self.0.send_replace(celsius); // overwrite and return
        Ok(())
    }
}
}

The handler overwrites one slot and returns, so the queue is one value deep no matter how slow the code reading the watch::Receiver is, and the transaction buffers are freed as fast as they arrive. "The consumer is slow" means its processing is slow, not its binder handler. oneway calls to one object arrive in order, so the last value written is the latest one. This is the ordinary callback pattern, and it is what AOSP's own state listeners do.

Two refinements, for when they apply:

  • A slow link, where sending every value is itself the cost: put the same watch on the producer side, and have one task send the current value, wait for that call to finish, and read the slot again. While the link is backed up the slot is simply overwritten, and the transport's own back-pressure paces the sender.
  • A frozen Android app as the listener: the kernel can replace a queued oneway transaction with a newer one to the same method, which keeps a cached app's buffer from filling up. It does so only when the call carries FLAG_UPDATE_TXN, which a generated proxy never sets — send that one call by hand with submit_transact, as example-hello/src/bin/update_txn_interop_client.rs does.

A C++ or NDK peer

A Java peer is out of scope: AOSP has no Java FMQ.

Compile the three .aidl files from rsbinder/aidl/stream/ with the platform's aidl — StreamEndpoint.aidl imports android.hardware.common.fmq.MQDescriptor, so the platform's FMQ AIDL has to be on the include path — and implement the side you need. The SDK build-tools aidl does not parse the @FixedSize T in the current FMQ files; include the frozen V1 API instead (hardware/interfaces/common/fmq/aidl/aidl_api/android.hardware.common.fmq/1 and hardware/interfaces/common/aidl/aidl_api/android.hardware.common/1), which has the same wire. Which wire you have to speak is decided by the endpoint you receive or make: a ring on kernel binder, a sink alone over RPC.

The generated StreamEndpoint is a class template, and the service's methods take the instance the .aidl names — StreamEndpoint<LogLine>, or StreamEndpoint<int32_t> for StreamEndpoint<int>. The C++ compiler checks that much. It does not check the items: they are bytes a peer encodes and decodes itself, so reading them as the type argument names is up to the peer.

Over kernel binder the contract is the ring, and StreamEndpoint.aidl is its specification: attach the MQDescriptor with libfmq (AidlMessageQueue<int8_t, SynchronizedReadWrite>), read or write whole records — 4-byte little-endian header, top bit the kind, low 31 bits the payload length — and use the EventFlag bits as documented there. A producer keeps the last 256 bytes free of item records so the end record always fits, and waits with the mask NOT_FULL | CANCEL rather than writeBlocking, which would not see the cancel. A consumer making the endpoint allocates the ring itself and puts a binder in sink for the producer to link to for death; no IStreamSink method is called on it. What else a libfmq peer must do, with the details in StreamEndpoint.aidl:

  • Attach with AidlMessageQueue(desc, /*resetPointers=*/false): the other end may already have written.
  • Wake the other side after every read and every write, as libfmq itself does, not only on a full-to-not-full change: a waiter may need room for more than one record. A read that takes several records at once may wake once for all of them, as rsbinder's consumer does.
  • As the producer, load getEventFlagWord() before each item record and stop on CANCEL (libfmq has no peek), and remember a cancel your wait returned: the wait consumes the bit.
  • Treat the ring as the other process's memory. A producer applies the checks rsbinder applies before it maps (a memfd sealed against shrinking or an ashmem region, an EventFlag word, a size bound, a capacity above 260 bytes); a consumer checks each record's length against the ring before reading it.

An NDK peer cannot link libfmq — it is not part of the NDK, and the device's libfmq.so is built against the platform's C++ library rather than the NDK's. Two header-only C11 files do that part instead: rsbinder-fmq/c/rsbinder_fmq.h is the ring (attach with the same checks rsbinder makes, create a sealed memfd, the counters, the EventFlag futex), and contrib/ndk/rsbinder_stream.h is the record layer on it (rsbs_send, rsbs_end, rsbs_recv, rsbs_cancel). The NDK compiles them with no extra flag; on a 32-bit glibc host they need -D_FILE_OFFSET_BITS=64, without which the queue header stops with #error. For death, create the AIBinder_DeathRecipient with rsbs_on_binder_died, set rsbs_on_unlinked as its onUnlinked (API 33), and link with the stream's rsbs_*_death_cookie: libbinder can still be running the death callback after AIBinder_unlinkToDeath returns, and the cookie keeps what it touches mapped until onUnlinked, so the stream may be closed at any time. Each header has two C++ templates that convert to and from the NDK backend's generated MQDescriptor and StreamEndpoint. example-hello/cpp/stream_interop.cpp is a complete client on the two headers for both directions, example-hello/cpp/stream_libfmq_interop.cpp the same client with its ring on libfmq, and run_stream_interop.sh runs both against rsbinder on an emulator.

Over RPC the contract is the two interfaces, and the client opens incoming connections as above (libbinder's ARpcSession_setMaxIncomingThreads). example-hello/cpp/stream_rpc_interop.cpp implements both ends on the device's libbinder RpcSession, and run_stream_rpc_interop.sh runs it against rsbinder:

  • As the consumer, implement IStreamSink. In onStart, turn the binder into the interface (IStreamSource::asInterface, NDK IStreamSource::fromBinder) and link to its death. In onBatch, put the bytes into a parcel — C++ Parcel::setData, NDK AParcel_unmarshal followed by AParcel_setDataPosition(p, 0) — and read the item type count times. Call request(total) as you drain, where total is how many batches you have drained since the stream began; if the call fails, call it again later with whatever the total is by then. The producer opens with the credits that onStart states, so the first grant can follow the first batch. To bound your queue, hold the producer to credits plus the highest total you have sent or tried to send, and decide in onStart how large a credits you accept.
  • As the producer, implement IStreamSource and count: each onBatch spends one credit, onEnd spends none, and after cancel only onEnd may still be sent. request(total) is worth total minus the highest total you have seen, and nothing when that is not positive — keep the highest, not a sum. Open with at least one credit of your own, and state it in onStart. An rsbinder consumer grants only for batches it has already drained, so a producer that starts at zero waits for a grant that nothing can trigger — and it ends the stream on a batch sent beyond the stated window plus its grants, or on a window wider than it accepts (4 unless it was made with a larger ReceiverPolicy::max_opening). A batch sent before onStart has no credit and ends the stream with EX_ILLEGAL_STATE, a negative count with EX_ILLEGAL_ARGUMENT. An rsbinder consumer sends cancel when its receiver is dropped, also after a clean end, so ignore a cancel that arrives after your onEnd. If an onBatch fails in a way that does not tell you whether it arrived, stop sending batches and send onEnd: you no longer know how much credit you have. onEnd's three arguments are the fields of a binder::Status; -129, -127 and -128 are never sent.

Error Handling

Binder IPC introduces failure modes that do not exist in ordinary function calls: the remote process may crash, the kernel driver may reject a transaction, or the service may intentionally signal an application-level error. rsbinder represents all of these cases through two complementary types -- StatusCode for transport-level errors and Status for richer application-level errors that include exception codes and optional messages.

Core Types

rsbinder::BinderResult<T>

Every AIDL-generated method returns this type:

#![allow(unused)]
fn main() {
pub type BinderResult<T> = std::result::Result<T, Status>;
}

This is a standard Result whose error variant is a Status, and BinderResult is the name you will see in your generated files.

Note — two Result aliases coexist in rsbinder. AIDL methods use rsbinder::BinderResult<T> = Result<T, Status> (rich error with exception code + optional message), but several library-level APIs (Interface::dump, hub::default, hub::register_for_notifications, hub::get_service_debug_info) use the plain rsbinder::Result<T> = Result<T, StatusCode>. Because StatusCode implements From<StatusCode> for Status, propagating with ? from a Result<_, StatusCode> into an AIDL method body (which returns Result<_, Status>) works transparently.

StatusCode

StatusCode represents low-level transport errors that occur before or during a Binder transaction. It is named rsbinder::StatusCode.

Commonly encountered values:

VariantMeaning
OkOperation completed successfully
UnknownAn unspecified error occurred
BadValueInvalid parameter value
UnknownTransactionThe transaction code is not recognized
PermissionDeniedCaller does not have permission
DeadObjectThe remote process has died
FailedTransactionThe transaction could not be completed
NoMemoryOut of memory
BadTypeWrong data type encountered
NotEnoughDataThe parcel did not contain enough data

A StatusCode can be converted directly into a Status:

#![allow(unused)]
fn main() {
let status: rsbinder::Status = rsbinder::StatusCode::PermissionDenied.into();
}

Status

Status combines three pieces of information:

  • Exception code (ExceptionCode) -- categorizes the error (e.g. ServiceSpecific, Security, NullPointer).
  • Status code (StatusCode) -- provides transport-level detail.
  • Message (Option<String>) -- an optional human-readable description.

Key methods on Status:

#![allow(unused)]
fn main() {
// Check the category of the error
status.exception_code()      // -> ExceptionCode

// Get the transport error (only meaningful when exception is TransactionFailed)
status.transaction_error()   // -> StatusCode

// Get the service-specific error code (only meaningful when exception is ServiceSpecific)
status.service_specific_error() // -> i32

// The same code as your own type (see "Typed Error Codes" below)
status.service_error::<LookupError>() // -> Option<LookupError>

// The message the sender attached, if any
status.message()             // -> Option<&str>

// Check if the status represents success
status.is_ok()               // -> bool
}

ExceptionCode

ExceptionCode classifies the kind of error:

VariantWire valueMeaning
None0No error
Security-1Security / permission violation
BadParcelable-2Malformed parcelable data
IllegalArgument-3Invalid argument provided
NullPointer-4Unexpected null value
IllegalState-5Operation invalid for current state
NetworkMainThread-6Network operation on main thread (Java compatibility)
UnsupportedOperation-7Requested operation is not supported
ServiceSpecific-8Application-defined error with custom code
Parcelable-9Embedded user parcelable exception (Java compatibility)
HasNotedAppOpsReplyHeader-127Internal: reply parcel carries an AppOps noted-ops header
HasReplyHeader-128Internal: reply parcel carries a header (Java-specific marker)
TransactionFailed-129Low-level transaction failure
JustError-256Generic error fallback used internally by the library

Returning Errors from a Service

Service-Specific Errors

The most common way for a service to report an application-level error is through Status::new_service_specific_error. This sets the exception code to ServiceSpecific and carries an integer error code whose meaning is defined by the service:

#![allow(unused)]
fn main() {
fn ThrowServiceException(
    &self,
    code: i32,
) -> rsbinder::BinderResult<()> {
    Err(rsbinder::Status::new_service_specific_error(code, None))
}
}

You can also attach an optional message:

#![allow(unused)]
fn main() {
Err(rsbinder::Status::new_service_specific_error(
    -1,
    Some("resource not found".into()),
))
}

Typed Error Codes

An i32 is what crosses the wire, but it does not have to be what your code handles. A type that implements rsbinder::ServiceSpecificError carries the mapping once, so neither end writes a match over bare numbers:

#![allow(unused)]
fn main() {
// A plain Rust enum (needs the `macros` feature):
#[derive(rsbinder::ServiceSpecificError, Clone, Copy, PartialEq, Eq, Debug)]
#[repr(i32)]
pub enum LookupError {
    NotFound = 1,
    Busy = 2,
}

fn lookup(&self, key: &str) -> rsbinder::BinderResult<i32> {
    Err(rsbinder::Status::service_specific(
        LookupError::NotFound,
        Some("no such key"),
    ))
}
}

For an enum that comes from .aidl, the generated type is an open newtype rather than a Rust enum, so attach the trait with a macro instead:

#![allow(unused)]
fn main() {
rsbinder::impl_service_specific_error!(my_package::LookupError::LookupError);
}

Either way the bytes are the ones AOSP's Status::fromServiceSpecificError produces — a C++ or Java peer reads the same code it always did. The code space is i32, so an .aidl enum backed by long cannot be an error type; attaching the trait to one is a compile error rather than a value that truncates on the wire.

Unimplemented Methods

When a service does not support a particular transaction (for example, a method added in a newer version of the AIDL interface), return UnknownTransaction:

#![allow(unused)]
fn main() {
fn UnimplementedMethod(
    &self,
    _arg: i32,
) -> rsbinder::BinderResult<i32> {
    // Indicate that this method is not implemented
    Err(rsbinder::StatusCode::UnknownTransaction.into())
}
}

The .into() conversion automatically creates a Status with the TransactionFailed exception code.

Permission Errors

If your service enforces access control, return PermissionDenied:

#![allow(unused)]
fn main() {
fn restricted_operation(&self) -> rsbinder::BinderResult<()> {
    let caller = rsbinder::thread_state::CallingContext::default();
    if caller.uid != ALLOWED_UID {
        return Err(rsbinder::StatusCode::PermissionDenied.into());
    }
    Ok(())
}
}

Handling Errors on the Client Side

Checking for Service-Specific Errors

When calling a service method, always check the Result for errors. For service-specific errors, inspect the exception_code() first, then retrieve the integer code:

#![allow(unused)]
fn main() {
let result = service.ThrowServiceException(-1);
assert!(result.is_err());

let status = result.unwrap_err();
assert_eq!(
    status.exception_code(),
    rsbinder::ExceptionCode::ServiceSpecific,
);
assert_eq!(status.service_specific_error(), -1);
}

With a typed error the two checks collapse into one, because service_error::<T>() answers None unless the status really is a service-specific failure carrying one of T's values:

#![allow(unused)]
fn main() {
let status = service.lookup("k").unwrap_err();
match status.service_error::<LookupError>() {
    Some(LookupError::NotFound) => { /* create it */ }
    Some(LookupError::Busy) => { /* retry later */ }
    // Also reached for a code this build does not know: a peer compiled
    // against a newer contract sends one, and `service_specific_error()`
    // still has the raw number to log or forward.
    _ => log::warn!("unhandled: {status}"),
}
}

Distinguishing Error Categories

A practical error-handling pattern checks the exception code to determine how to react:

#![allow(unused)]
fn main() {
match service.some_method() {
    Ok(value) => {
        // Success
    }
    Err(status) => {
        match status.exception_code() {
            rsbinder::ExceptionCode::ServiceSpecific => {
                let code = status.service_specific_error();
                eprintln!("Service error {code}: {status}");
            }
            rsbinder::ExceptionCode::TransactionFailed => {
                let transport_err = status.transaction_error();
                eprintln!("Transport error: {transport_err:?}");
            }
            rsbinder::ExceptionCode::Security => {
                eprintln!("Permission denied: {status}");
            }
            other => {
                eprintln!("Unexpected error ({other}): {status}");
            }
        }
    }
}
}

Detecting a Dead Service

If the remote process crashes, methods will fail with DeadObject:

#![allow(unused)]
fn main() {
let result = service.some_method();
if let Err(ref status) = result {
    if status.transaction_error() == rsbinder::StatusCode::DeadObject {
        eprintln!("Service has died, attempting reconnection...");
    }
}
}

DeadObject alone does not prove the service died — a live handler of an in-process service, or of a service not built on rsbinder, can return it too (rsbinder sends its own remote handlers' DeadObject as FailedTransaction) — and over RPC the call that ended a session can return TimedOut instead. Reconnecting watches the service and reconnects for you.

Converting Between Error Types

rsbinder provides From implementations that make conversions between StatusCode, ExceptionCode, and Status straightforward:

#![allow(unused)]
fn main() {
// StatusCode -> Status
let status: rsbinder::Status = rsbinder::StatusCode::BadValue.into();

// ExceptionCode -> Status
let status: rsbinder::Status = rsbinder::ExceptionCode::IllegalArgument.into();

// Status -> StatusCode: the code the status carries, or `FailedTransaction`
// for an exception that carries none. A status built from
// `ExceptionCode::ServiceSpecific` carries `ServiceSpecific(0)`.
let code: rsbinder::StatusCode = status.into();
}

These conversions are used most often in the Err(...) return position of service methods, where .into() converts a StatusCode into the expected Status type automatically.

Tips

  • Split the two kinds of failure. Status::new_service_specific_error is for what the caller's own logic cares about — "item not found", "quota exceeded" — with the codes declared as constants both sides share. A raw StatusCode like PermissionDenied or BadValue is for the IPC mechanism failing.

  • Check exception_code() before anything else. service_specific_error() and transaction_error() are only meaningful for their own exception code; called on any other, they return zero rather than an error.

  • Expect DeadObject in a long-running client. A service can restart. link_to_death tells you when, so you can reconnect instead of failing every subsequent call — or let Reconnecting do it.

  • Status implements Display and std::error::Error, so it works with ? in a function returning Box<dyn Error> and prints usefully in a log.

Reconnecting to a Service

A service can go away: its process crashes or restarts, or — over RPC — its session ends because one connection failed, a reply deadline expired or the server closed it. Every proxy to it then returns DeadObject, and getting back means looking the service up again, on a new session for RPC, and registering your callbacks again. rsbinder::Reconnecting does that for one service.

use rsbinder::{Endpoint, Reconnecting};

let listener = listener.clone();
let hello = Reconnecting::<dyn IHello>::builder(Endpoint::unix("/run/hello.sock").with_service("hello"))
    .on_connect(move |conn| {
        // Every (re)connection, before callers get the new proxy.
        conn.proxy().register_listener(&listener)?;
        Ok(())
    })
    .build()?;

let reply = hello.with(|h| h.echo("hi"))?;

The Uri is any the entry API takes: a kernel endpoint with a service name (Uri::kernel().with_service(name), binder://name) for the service manager, or an RPC endpoint with one (#name). Leave the service name out to connect to a server's root object — the form a libbinder RpcServer::setRootObject server needs, since only an rsbinder server has a directory of names.

What happens when the service goes away

The helper notices, starts a thread of its own, looks the service up again — backing off from 10 ms to at most 1 s between failed attempts — runs on_connect, and only then hands the new proxy to callers. generation() counts the connections made.

How it notices depends on what the lookup returned:

Looked-up serviceNoticed by
a kernel binder proxyits death notification, at once
RPC, the session has incoming connections or a serve loopits death notification, as the session ends
RPC otherwise — the default unix://…#namethe next call: before it is sent where the socket reports the close (Unix sockets; TCP on Linux/Android), otherwise after that call fails
a service in the same processnever: it cannot die on its own

It never decides from a status code. The call that ended a session can return TimedOut, and a live server's handler can return DeadObject.

Calls while it reconnects

with runs your closure once and returns its result. A failed call is never sent again: only the service knows whether a call may run twice.

With no connection, with returns DeadObject — but if a reconnect attempt is in progress it first waits for it, up to ReconnectPolicy::call_wait (1 s). A server that is already back answers an attempt within milliseconds, so the call runs on the new connection. Between attempts — the server still down — calls fail at once instead of each waiting a second. Inside a binder transaction or inside on_connect nothing waits: the reconnect may be waiting on that very thread.

To wait across attempts, use wait_connected(timeout), or connected().await with tokio. current() returns the proxy without waiting, for async code that must not block.

Starting before the server

build() tries once without waiting. It fails only for what another attempt cannot change — a Uri that serve / connect would refuse, options that do not fit it, a missing feature, a kernel endpoint without a service name, no binder device for a kernel endpoint (NoInit), a kernel service of another interface (BadType), an RPC name lookup on a server without a directory of names (BadType), a name the service manager refuses to watch, or ConnectError::stop from on_connect. A server that is not up yet is not an error: the helper connects in the background, so a client may start first. Set ClientOptions::timeout through options(...) if the server could accept a connection and never answer.

on_connect

Register callbacks, subscribe, restore what the server lost. Return Err(ConnectError::stop(code)) to give up for good; anything else — including a ? on a call that failed — counts as a failed attempt and is retried. A callback the server makes while the hook runs should use conn.proxy(): the helper hands out the new proxy only after the hook returns.

Lifetime

Dropping the Reconnecting (or close()) closes the current RPC session and stops any reconnect in progress after its current step, without waiting for it. A callback object that needs the helper should hold helper.downgrade() — a WeakReconnecting — not the helper: a strong handle there keeps the helper, its session and the callback alive in a cycle.

The helper does not register services. A service that outlives a restarted service manager must add itself again; on Android, init restarts the services that depend on servicemanager.

Observability

Three pieces tell you what a binder service is doing and for whom:

PieceWhat it answersAndroid counterpart
Work sourceOn whose behalf is this call being made?IPCThreadState::setCallingWorkSourceUid, Java Binder.setCallingWorkSourceUid
Transaction namesWhich AIDL method is transaction code 7?aidl --trace
Transaction observersWhat did every incoming call cost, and did it fail?Java Binder.setObserver / BinderCallsStats

None of them changes the wire format. The work source rides in a field every kernel binder request header already has; names and observers are local to the process. All three are new in 0.12.0.

Work source

A service that does work for someone else — a media server decoding for an app, a proxy forwarding a request — can say so. The work source is a uid attached to outgoing calls, separate from the calling uid the kernel reports, so the next service can attribute cost to the uid that asked for the work rather than to the process that forwarded it.

#![allow(unused)]
fn main() {
use rsbinder::{restore_calling_work_source, set_calling_work_source_uid};

let token = set_calling_work_source_uid(app_uid);
let result = decoder.decode(&frame);  // the request header carries app_uid
restore_calling_work_source(token);
}

On the receiving side the handler reads it:

#![allow(unused)]
fn main() {
fn decode(&self, frame: &Frame) -> rsbinder::status::Result<Image> {
    let attributed_to = rsbinder::get_calling_work_source_uid(); // u32::MAX: unset
    // ...
}
}

The rules follow AOSP:

  • The work source is per thread. A client thread can set it outside any transaction, and every kernel binder call that thread makes carries it.
  • A handler sees what its caller sent. A received value is not forwarded on the handler's own outgoing calls unless the handler sets it again, so a work source never travels further than the service that chose to pass it on.
  • When the handler returns, the thread's previous value comes back; nothing a handler sets leaks into the next call served on that thread.
  • clear_calling_work_source sends "unset" explicitly, clear_propagate_work_source stops sending it, and should_propagate_work_source reports which is in effect.

RPC has no work-source field — AOSP's RPC wire has none either. Inside an RPC handler get_calling_work_source_uid reports the unset value; a value set there still propagates to the kernel binder calls the handler makes. The functions work in a pure-RPC process too.

Transaction names

Generated code identifies methods by transaction code. To let tools name them, generate the interface with a name table, as AOSP does with aidl --trace:

#![allow(unused)]
fn main() {
// build.rs
rsbinder_aidl::Builder::new()
    .source(PathBuf::from("aidl/IDecoder.aidl"))
    .trace(true)
    .output(PathBuf::from("decoder.rs"))
    .generate()?;
}

The generated service then answers Remotable::transaction_name(code) with the AIDL method name, including the stable-AIDL meta methods getInterfaceVersion and getInterfaceHash. The table is off by default, as in AOSP, because it costs one &'static str per method. Hand-written Remotables and interfaces declared with the interface macros keep the default, which returns None.

With trace(true) the generated proxies also open a client-side span per call when the tracing feature is on (see below).

Transaction observers

A TransactionObserver is called around every transaction the process serves, on kernel binder and over RPC: on_transact before the handler runs and on_reply after it returns, before the reply is sent.

#![allow(unused)]
fn main() {
use std::any::Any;
use std::sync::Arc;
use std::time::Duration;
use rsbinder::observe::{set_observer, TransactionObserver, TxnContext};

struct SlowCallLog;

impl TransactionObserver for SlowCallLog {
    fn on_transact(&self, _ctx: &TxnContext<'_>) -> Option<Box<dyn Any + Send>> {
        None
    }

    fn on_reply(
        &self,
        ctx: &TxnContext<'_>,
        _tag: Option<Box<dyn Any + Send>>,
        result: &rsbinder::Result<()>,
        elapsed: Duration,
    ) {
        if elapsed > Duration::from_millis(100) {
            log::warn!(
                "{} {} took {elapsed:?} for uid {}: {result:?}",
                ctx.descriptor,
                ctx.method.unwrap_or("?"),
                ctx.calling_uid,
            );
        }
    }
}

set_observer(Some(Arc::new(SlowCallLog)));
}

TxnContext carries the descriptor, the code, the method name (with trace(true)), the one-way flag, the caller's uid and pid, and the transport (TransportCaps::KERNEL or the RPC session's capabilities). Whatever on_transact returns comes back to on_reply as tag — a start marker or a span, for example.

What an implementation can rely on:

  • Both calls run on the thread that serves the transaction, with no rsbinder lock held, so an observer may make binder calls of its own. Its time is added to the caller's latency.
  • Every on_transact is followed by exactly one on_reply, one-way calls included.
  • A panic in either call is caught and logged; it does not change the transaction's result.
  • result is the transport-level result. An AIDL method that returns an exception or a service-specific error writes it into the reply and still reports Ok(()) here.
  • The kernel reports no sender pid for a one-way call, so calling_pid is 0 there.
  • Over RPC, the session answers INTERFACE and PING itself, so those two meta transactions are observed on kernel binder only.

There is one observer per process, because kernel binder serves every object from one process-wide thread pool. Filter on ctx.descriptor or ctx.transport to watch a subset, and write an observer that forwards to several to combine them. With none installed, a dispatch pays one atomic load.

Provided observers

  • LogObserver writes one debug line per transaction, target rsbinder::observe.

  • StatsObserver keeps, per descriptor and code, the call count, transport-error count, total and maximum handler time, and a power-of-two microsecond latency histogram, plus how many transactions are being served at once and the peak of that number:

    #![allow(unused)]
    fn main() {
    use rsbinder::observe::{set_observer, StatsObserver};
    
    let stats = Arc::new(StatsObserver::new());
    set_observer(Some(stats.clone()));
    // ... serve ...
    let snapshot = stats.snapshot();
    for m in &snapshot.methods {
        println!("{} {:?}: {} calls, max {:?}", m.descriptor, m.method, m.calls, m.max);
    }
    println!("peak concurrency {}", snapshot.max_in_flight);
    }

    A peak equal to the thread-pool size is a hint, not proof, that the pool ran out: a callback nested on the same thread counts twice.

Tracing spans

The tracing feature (off by default) produces spans named as AOSP names its ATrace sections:

AIDL::rust::<descriptor>::<method>::server
AIDL::rust::<descriptor>::<method>::client

A code the name table does not cover is written #<code>, as in AOSP. Because a tracing span name must be a &'static str, the string is the name field of a span called aidl (target rsbinder::aidl, level TRACE).

  • Server spans come from observe::TracingObserver. Install it like any other observer; the span covers the handler, so spans the handler opens — including the client span of a nested AIDL call — are its children.
  • Client spans come from proxies generated with trace(true). The span is created where the call is made, so its parent is the caller's current span, for the async proxy as well.
[dependencies]
rsbinder = { version = "0.12", features = ["tracing"] }
#![allow(unused)]
fn main() {
tracing_subscriber::fmt()
    .with_env_filter("rsbinder::aidl=trace")
    .init();
rsbinder::observe::set_observer(Some(Arc::new(rsbinder::observe::TracingObserver)));
}

Without the feature the generated client hook is a plain function call, and code generated without trace(true) is unchanged.

Service Manager (HUB)

The service manager is the central registry for Binder services. Every service that wants to be discoverable by other processes registers itself with the service manager under a well-known name, and every client that needs a service looks it up by that name. In rsbinder, the service manager is referred to as HUB and is accessed through the rsbinder::hub module.

On Linux, the HUB is provided by the rsb_hub binary that ships with the rsbinder-tools crate. On Android, the system's native servicemanager fulfills this role, and rsbinder talks to it using the same Binder protocol.

Note: This chapter covers the kernel-binder service manager. The RPC transport (binder-over-socket) uses no service manager at all — RpcServer::set_root publishes the root binder directly and RpcSession::get_root fetches it. The Android 16 Accessor pattern bridges the two: a kernel-binder service of type IAccessor hands a client an RPC socket fd. See RPC Transport.

Running the Service Manager (Linux)

Before any service can register or any client can perform lookups, the HUB process must be running:

# Build and run the service manager, with no access control (development)
$ cargo run --bin rsb_hub -- --insecure-allow-all

rsb_hub opens the Binder device, becomes the context manager (handle 0), and enters a loop that processes registration and lookup requests. It must remain running for the lifetime of the system's Binder services.

It also refuses to start without an access-control policy. Every request is denied unless a policy allows it, and --insecure-allow-all above is the explicit opt-out for development. See Access control for how to write one.

Running it under systemd

rsb_hub handles three signals: SIGHUP reloads the configuration in place, and SIGTERM / SIGINT stop it cleanly — exiting 0 rather than dying by signal, so a deliberate stop is distinguishable from a crash in the journal.

Use Type=notify. The HUB reports READY=1 only once it holds handle 0, which is the first instant a client can reach it; with Type=simple systemd would consider the unit started as soon as exec returned, and every unit ordered After= it would race the registry.

[Unit]
Description=rsbinder service manager
After=dev-binderfs.mount

[Service]
Type=notify
ExecStart=/usr/bin/rsb_hub --config /etc/rsbinder/hub.d
ExecReload=/bin/kill -HUP $MAINPID
Restart=on-failure

[Install]
WantedBy=multi-user.target

A binder device has exactly one service manager — the kernel enforces that, not rsb_hub — so a second instance on the same device exits 1 and says so. To run an independent one, give it its own device: sudo rsb_device other then rsb_hub --device other.

Inspecting a running HUB

The rsb_service CLI, also from rsbinder-tools, is the Linux counterpart of Android's service and dumpsys -l:

$ rsb_service list                                  # what is registered
$ rsb_service info                                  # ... and which pid owns each
$ rsb_service check com.example.IFoo/default        # is it up yet?
$ rsb_service declared com.example.IFoo/default     # is it even configured?
$ rsb_service instances com.example.IFoo            # every declared instance
$ rsb_service connection com.example.IFoo/default   # declared ip:port, if any
$ rsb_service dump manager                          # rsb_hub's own registry

dump manager is the one with no Android equivalent — AOSP's servicemanager does not implement dump at all, because on Android the same questions are answered by dumpsys, service list and the init/VINTF files:

rsb_hub 0.12.0
access control: enforcing, 4 rule(s)
declarations: 2
death subscriptions: 3

services (2):
  com.example.IFoo/default
    pid=92338 uid=973 dump_priority=0x8 lazy=no accessor=no
    clients=no guarantee_client=no callbacks: registration=0 client=0
  manager
    pid=92334 uid=973 dump_priority=0x8 lazy=no accessor=no
    clients=no guarantee_client=yes callbacks: registration=0 client=0

awaiting registration (1):
  com.example.INope/default  callbacks: registration=1 client=0

That last section is usually the one you came for: a name something is waiting on that nothing has registered. Arguments narrow the listing by substring (rsb_service dump manager com.example), and dump works on any service, not just the HUB — it sends DUMP_TRANSACTION, which for an rsbinder service lands in its Interface::dump implementation.

Exit status is the answer: 0 yes, 1 no, 2 the question could not be answered (no service manager, or its policy denied it), so rsb_service check foo || start-foo reads the way it looks. rsb_service is an ordinary binder client, so the HUB's policy applies to it like anything else — list and info need list, and every name they report is filtered by find; dump <name> needs find on <name>, and for manager also list.

Registering a Service

To make a service available to other processes, create a Binder object and register it with hub::add_service:

#![allow(unused)]
fn main() {
use rsbinder::*;

// Create the Binder service object
let service = BnMyService::new_binder(MyServiceImpl);

// Register it under a descriptive name. `add_service` accepts anything
// convertible into `SIBinder`, so the typed `Strong<dyn IMyService>` can
// be passed directly — no `.as_binder()` needed.
hub::add_service("com.example.myservice", &service)?;
}

The name passed to add_service is the identifier that clients will use to find the service.

Registration Rules

The service manager enforces several constraints on service names. These checks are applied by the service manager process (rsb_hub on Linux, servicemanager on Android) — the rsbinder library itself does not validate the name on the client side, so the failure surfaces as a Status error from the IPC call rather than a local compile-time or pre-flight check.

  • Maximum length: 127 characters. Names of 128 characters or longer are rejected.
  • Allowed characters: Alphanumeric characters, dots (.), underscores (_), hyphens (-), and forward slashes (/). Special characters such as $ are not allowed.
  • Non-empty: An empty string is rejected.
  • Overwrite rules: Re-registering the same binder under a name, or replacing a registration from the same UID (e.g. a restarted service process), is always allowed. However, rsb_hub rejects an attempt by a different UID to overwrite an existing name with a different binder — a likely service-name hijack — unless it was started with the opt-in --allow-cross-uid-overwrite flag.
#![allow(unused)]
fn main() {
let service = BnFoo::new_binder(FooImpl {});

// Empty names are rejected
assert!(hub::add_service("", &service).is_err());

// Valid name
assert!(hub::add_service("foo", &service).is_ok());

// Maximum length (127 characters)
let long_name = "a".repeat(127);
assert!(hub::add_service(&long_name, &service).is_ok());

// Too long (128 characters)
let too_long = "a".repeat(128);
assert!(hub::add_service(&too_long, &service).is_err());

// Special characters are rejected
assert!(hub::add_service("happy$foo$fo", &service).is_err());
}

Looking Up Services

rsbinder provides a family of lookup functions. They differ only in how they wait and how they encode "not registered" — pick by what your client needs:

FunctionWaits?ReturnsNot registered
wait_for_interface::<T>blocks until registeredResult<Strong<T>>blocks
wait_for_serviceblocks until registeredOption<SIBinder>blocks
check_interface::<T>noResult<Strong<T>>Err(NameNotFound)
check_servicenoOption<SIBinder>None
try_get_interface::<T>noResult<Option<Strong<T>>>Ok(None)
try_get_servicenoResult<Option<SIBinder>>Ok(None)

The three awaitable ones live at the crate root rather than in hub, because only they have to be awaited (the tokio feature, on by default):

FunctionWaits?ReturnsNot registered
wait_for_interface_async::<T>awaits until registeredResult<Strong<T>>awaits
check_interface_async::<T>noResult<Strong<T>>Err(NameNotFound)
get_interface_async::<T>noResult<Strong<T>>Err(NameNotFound)

Removed in 0.11.0: hub::get_service and hub::get_interface are gone (deprecated in 0.10.0) because their wait behavior was inconsistent across Android versions. try_get_service / try_get_interface issue the same getService wire call; use wait_for_service / wait_for_interface to block until the service appears, or check_service / check_interface for a non-blocking lookup that does not start a lazy service — except on Android 15 r6+ (the android_15 protocol), where checkService returns a union rsbinder does not parse, so check_service routes through getService and does start one.

Blocking, Type-Safe Lookup with wait_for_interface

The most common client pattern is hub::wait_for_interface, which blocks until the service is registered and casts it to the expected AIDL interface type in one step — the equivalent of AOSP's waitForService:

#![allow(unused)]
fn main() {
let service: rsbinder::Strong<dyn IMyService::IMyService> =
    hub::wait_for_interface("com.example.myservice")?;

// Now call methods directly on the typed proxy
let result = service.some_method()?;
}

The wait is unbounded: it returns when the service appears, and fails only if the service manager itself is unreachable. The wait is event-driven (registration-callback based) when the process runs a binder thread pool (ProcessState::start_thread_pool()); without one it degrades gracefully to ~1-second polling.

The same wait from async code

rsbinder::wait_for_interface_async is the awaitable form — same contract, same event-driven wake, and it can be given up on:

#![allow(unused)]
fn main() {
let service = tokio::time::timeout(
    Duration::from_secs(5),
    rsbinder::wait_for_interface_async::<dyn IMyService::IMyService>("com.example.myservice"),
).await??;
}

timeout is tokio/time, which rsbinder's tokio feature does not enable; declare it on your own tokio dependency.

Dropping the future (a timeout that expires, a select! arm that loses) ends the wait and unregisters the callback it placed with the service manager. Do not hand-roll it as spawn_blocking(hub::wait_for_interface): dropping that future only detaches the JoinHandle, leaving a blocking-pool thread waiting for a service nobody is waiting for any more.

Each outstanding wait holds one thread of the same spawn_blocking pool your outbound calls go through, so this is for the handful of waits a process does at start-up — not for polling a name in a loop.

hub::wait_for_service is the untyped variant — it returns Option<SIBinder> when you need the raw handle (for example, to inspect the descriptor or pass it to another API):

#![allow(unused)]
fn main() {
if let Some(binder) = hub::wait_for_service("com.example.myservice") {
    println!("Found service with descriptor: {}", binder.descriptor());
}
}

Non-Blocking Check with check_service / check_interface

hub::check_service returns immediately, with None if the service is not (yet) registered:

#![allow(unused)]
fn main() {
let binder: Option<SIBinder> = hub::check_service("com.example.myservice");
if binder.is_some() {
    println!("Service is available");
} else {
    println!("Service is not yet registered");
}
}

The typed counterpart hub::check_interface casts in one step and returns Err(StatusCode::NameNotFound) immediately when the service is absent.

Error-Preserving Lookup with try_get_service / try_get_interface

check_service folds "not registered" and "service manager unreachable" into the same None. When you must tell those apart, use hub::try_get_service (or the typed hub::try_get_interface), which returns Result<Option<_>>: Ok(None) means the service is not registered, while Err(..) means the lookup itself failed (transport or service-manager error):

#![allow(unused)]
fn main() {
match hub::try_get_service("com.example.myservice") {
    Ok(Some(binder)) => println!("found: {}", binder.descriptor()),
    Ok(None) => println!("service not registered"),
    Err(err) => println!("service manager unreachable: {err:?}"),
}
}

Listing Registered Services

To enumerate all services currently registered with the HUB, use hub::list_services with a dump priority flag:

#![allow(unused)]
fn main() {
let services = hub::list_services(hub::DUMP_FLAG_PRIORITY_DEFAULT);
for name in &services {
    println!("Available: {}", name);
}
}

The dump priority flag filters which services are returned. The most commonly used flags are:

FlagDescription
DUMP_FLAG_PRIORITY_DEFAULTServices with default priority
DUMP_FLAG_PRIORITY_HIGHHigh-priority services
DUMP_FLAG_PRIORITY_CRITICALCritical system services
DUMP_FLAG_PRIORITY_NORMALNormal-priority services
DUMP_FLAG_PRIORITY_ALLAll services regardless of priority

list_services reports an unreachable or unwilling service manager as an empty list. When you need to tell "nothing is registered" from "the policy denied this caller", use hub::try_list_services, which returns Result<Vec<String>, Status> and preserves the EX_SECURITY status — and its message — that rsb_hub sends on a denial. The same pairing exists for is_declared / try_is_declared, get_declared_instances / try_get_declared_instances, get_connection_info / try_get_connection_info, and get_service_debug_info / try_get_service_debug_info. This is exactly what rsb_service uses to report a denial as a denial.

Service Notifications

You can register a callback that fires whenever a service with a particular name is registered (or re-registered). This is useful for clients that start before the service they depend on is available.

Defining a Callback

Implement the hub::IServiceCallback trait:

#![allow(unused)]
fn main() {
struct MyServiceCallback;

impl rsbinder::Interface for MyServiceCallback {}

impl hub::IServiceCallback for MyServiceCallback {
    fn onRegistration(
        &self,
        name: &str,
        service: &rsbinder::SIBinder,
    ) -> rsbinder::BinderResult<()> {
        println!("Service registered: {name}");
        Ok(())
    }
}
}

Registering and Unregistering

Wrap the callback in a Binder object and pass it to the HUB:

#![allow(unused)]
fn main() {
let callback = hub::BnServiceCallback::new_binder(MyServiceCallback);

// Start receiving notifications
hub::register_for_notifications("com.example.myservice", &callback)?;

// Later, when notifications are no longer needed
hub::unregister_for_notifications("com.example.myservice", &callback)?;
}

The callback will be invoked each time a service matching the given name is registered, including if it is re-registered after a restart.

Thread pool required. onRegistration arrives as an inbound binder transaction, so it never fires unless the client is running one.

Checking if a Service is Declared

hub::is_declared checks whether a service name has been declared in the system's service configuration (VINTF manifest on Android). This is distinct from whether the service is currently running:

#![allow(unused)]
fn main() {
let declared = hub::is_declared("com.example.myservice");
if declared {
    println!("Service is declared in the manifest");
} else {
    println!("Service is not declared");
}
}

On Linux with rsb_hub, this always returns false because rsb_hub has no VINTF parser and no manifest concept (rsb_hub.rs implements isDeclared as Ok(false)). On Android, it reflects the device's hardware interface declarations.

Debug Information

For diagnostics, hub::get_service_debug_info returns metadata about every registered service, including its name and the PID of the process hosting it:

#![allow(unused)]
fn main() {
let debug_info = hub::get_service_debug_info()?;
for info in &debug_info {
    println!("Service: {} (pid: {})", info.name, info.debugPid);
}
}

The returned ServiceDebugInfo struct has two fields:

  • name (String) -- the registered service name.
  • debugPid (i32) -- the PID of the process that registered the service.

This feature is available on Android 12 and above. On Android 10 and Android 11, calling get_service_debug_info returns an error.

get_service_debug_info flattens the service manager's Status into a StatusCode, which turns a policy denial into an anonymous FailedTransaction. hub::try_get_service_debug_info keeps the Status, message included.

Access control

rsb_hub gates every entry point on a policy. There is no permissive default: a name with no matching rule is denied, a rule that does not mention a permission denies it, and a policy that fails to load stops the process from starting.

Why it does not use SELinux

Android's servicemanager gates the same three permissions through SELinux. SELinux is not present on every Linux distribution and does not exist on macOS, so it cannot be the basis of the model here — and it does not need to be. Android's policy model is SELinux-independent:

(caller identity, service name) -> {add, find, list}

SELinux is only one way of expressing "caller identity". rsb_hub keeps the model and swaps that identity for uid, which every platform reports and the binder driver fills in itself (sender_euid) at transaction time — unforgeable and not racy. This is the same conclusion D-Bus reached for the same problem: uid/group policy files as the portable base, with an LSM as an optional overlay rather than a prerequisite.

Note what is not used: pid. Pid reuse races make every pid-derived attribute (/proc/<pid>/exe, cgroup, systemd unit) unsound as an authorization key — which is why AOSP calls its own field debugPid. Groups are usable because resolving uid → groups through NSS is race-free.

Permissions

PermissionGates
addadd_service, register_client_callback, try_unregister_service
findcheck_service, wait_for_service, try_get_service, register_for_notifications, is_declared, get_connection_info, and the per-name filtering of the two list calls
listlist_services, get_service_debug_info — a single global gate, since there is no name to scope it to

A denied lookup is reported as "not registered" rather than as an error. That matches AOSP (getService returns ok regardless of result) and keeps a denied caller from using the error to probe which names exist. Every other denial returns ExceptionCode::Security.

The two list calls apply both gates: the caller must be allowed to enumerate at all, and each name it would learn about must be one it may find. A name you cannot look up is a name you do not get told about.

Policy files

rsb_hub --config <PATH> takes a .toml file or a directory of them (default: /etc/rsbinder/hub.d). A directory is read as every *.toml in it whose name does not start with . (editor lock and backup files), sorted by file name — so 10-/20- prefixes control precedence the way they do in any other .d directory.

# /etc/rsbinder/hub.d/10-example.toml

[global]
# Who may enumerate service names at all. Omitted means nobody.
list = { group = ["binder-admin"] }

[[rule]]
name = "com.example.*"                       # trailing `*` only
add  = { user = ["exampled"] }
find = { group = ["example-clients"], uid = [0] }

[[rule]]
name = "*"
add  = "none"
find = "none"

A subject is the union of uid (numeric), user (resolved through NSS at load time), and group (matched against the caller's primary and supplementary groups). The shorthands "any" and "none" are also accepted; "any" must be written out, since an omitted key always means deny.

The first rule whose name matches decides, and evaluation stops there — a later rule never widens what an earlier one refused. That makes the rule governing any given name unique and greppable, which is worth more in a security file than the extra expressiveness of last-match-wins. Put catch-alls last.

Patterns support a trailing * and nothing else. A leading or interior * is rejected at load time rather than silently matching nothing.

Reloading

SIGHUP reloads the policy in place:

$ sudo kill -HUP "$(pidof rsb_hub)"

A reload that fails to parse or resolve keeps the policy already in force and logs the error. Dropping to deny-all would take the machine's IPC down over a typo; falling back to permissive would be worse. The reload also drops the memoized uid → groups sets, so group-membership changes take effect with it. A group added to a caller is also picked up without a reload — a call that a group rule denies re-reads the name service, at most once every 15 s per uid. A removed group keeps granting until the reload: a re-read only adds groups, because an answer read while a backend (e.g. sssd) is down can lack some and looks the same as a complete one.

Layers underneath

The policy is the second of two gates. The first is the binder device node itself: binder has no in-kernel access control of its own, so /dev/binderfs/<name> decides who can speak binder at all. rsb_device creates it 0600 (root only) and takes --group / --mode to widen that deliberately — the same model as /dev/kvm being 0660 root:kvm:

$ sudo groupadd -f binder
$ sudo usermod -aG binder "$USER"
$ sudo rsb_device binder --group binder --mode 0660

An LSM (SELinux, AppArmor) can sit on top as a third, AND-ed layer where the platform provides one, but it is never what makes the first two unnecessary.

Declaring services

An access rule says who may use a name. A declaration says the name is expected to exist at all — the question is_declared answers, and the one a client uses to tell "not installed" from "not started yet". On Android that comes from VINTF manifests; on Linux it comes from a [[service]] entry in the same configuration files.

[[service]]
name = "com.example.IFoo/default"          # pack.age.IFace/instance
start = { systemd = "example-foo.service" }
connection = { ip = "127.0.0.1", port = 8080 }

A declaration answers three calls:

CallAnswered from
is_declared("com.example.IFoo/default")the entry existing
get_declared_instances("com.example.IFoo")the instance halves of every entry for that interface
get_connection_info("com.example.IFoo/default")the connection table

Declared instances are filtered by find, so a caller only learns about instances it could look up.

Starting a service on demand

With a start entry, a try_get_service (or wait_for_interface) that misses the service asks for it to be started — AOSP's tryStartService, with the declaration standing in for the init property:

start = { systemd = "example-foo.service" }
# or
start = { exec = ["/usr/bin/exampled", "--instance", "default"] }

check_service never does this: it is documented as non-blocking and free of side effects. Only the try_get_* / wait_* families start anything (on Linux; see the Android 15 r6+ note above).

The start runs on its own thread and rsb_hub does not wait for it — what tells the client the service is up is the registration notification it is already waiting on, which is why wait_for_interface is the natural call here. At most one start per name and per activation is outstanding at a time — names that share one exec argv or one systemd unit share it, as init runs one service for several interfaces, and a name whose start a reload changed is not started again while its old start is outstanding — so a client polling a service that cannot come up does not spawn a copy per attempt. An exec start stays outstanding for the process's lifetime. exec takes an argv list, never a shell string.

The configuration is a trust boundary. A start entry runs with rsb_hub's privileges, and any client allowed to look the name up can trigger it. rsb_hub therefore refuses to start if the configuration, any file in it, or any directory on the way to them from / is writable by anyone but its owner, or is owned by someone other than root or rsb_hub itself — the same check sudo, ssh and cron apply to their own configuration. Symlinks are followed, and the directories holding them are held to the same rule; every inode is checked through the descriptor it is then read from. A world-writable directory such as /tmp fails even with the sticky bit. Keep it 0644 root-owned in a 0755 root-owned directory; for an unprivileged run, under $XDG_RUNTIME_DIR or $HOME.

Every exec program (argv[0]) is held to the same rule: the program and every directory on the way to it from /. A program that fails the check, or does not exist, fails the load like an untrusted configuration file does. On Linux and Android the program needs execute permission only; rsb_hub does not have to be able to read it. Other OSes have no O_PATH, so there the check opens the program for reading and the program needs read permission as well. The check runs when the configuration is loaded and again right before each start: a start whose program fails it then is refused and logged, which also covers a program that a failed SIGHUP reload judged untrusted while the previous policy stays in force. The start executes the path after that check, so a replacement made between the two is not caught; a path that passed can be replaced only by root or by rsb_hub's own uid.

Linux vs. Android Differences

While rsbinder aims for API compatibility across both platforms, there are important behavioral differences between the Linux HUB (rsb_hub) and Android's native servicemanager:

AspectLinux (rsb_hub)Android (servicemanager)
ProcessUser-space rsb_hub binarySystem servicemanager daemon
Access controluid/group policy files (above)SELinux MAC policy
Service declarations[[service]] entries (above)VINTF manifests
Service debug infoSupportedSupported (Android 12+; not on 10/11)
Binder deviceMust be created with rsb_deviceManaged by Android init
Version selectionAlways uses Android 16 protocolAuto-detected from SDK version
Death notificationsSupportedSupported
Diagnosticsrsb_service, and dump on the HUB itselfdumpsys / service; servicemanager has no dump
Readinesssd_notify(READY=1) under Type=notifyservicemanager.ready property

On Android, rsbinder automatically detects the SDK version and uses the appropriate service manager protocol (Android 10 through 16). The per-version API availability matrix:

APIAndroid 10Android 11Android 12+
check_service / wait_for_service✓✓✓
add_service✓✓✓
list_services✓✓✓
is_declaredfalse✓✓
register_for_notifications✗✓✓
unregister_for_notifications✗✓✓
get_service_debug_info✗✗✓

Where ✗ means the call returns StatusCode::UnknownTransaction (or false for the bool-returning is_declared) because the underlying server protocol predates the API. Android 10 falls back to the legacy C IServiceManager, which only learned the AIDL-based interface in Android 11; get_service_debug_info was added in Android 12. On Linux, rsbinder always uses the Android 16 protocol — what rsb_hub implements — so every row in the Android 12+ column applies. is_declared answers from the declarations above rather than from a VINTF manifest, so it is false only when nothing declares the name.

Using the ServiceManager Object Directly

The convenience functions (hub::add_service, hub::check_service, etc.) use a global singleton ServiceManager under the hood. If you need more control, you can obtain the ServiceManager instance directly:

#![allow(unused)]
fn main() {
use rsbinder::hub;

// `hub::default()` returns `Result<Arc<ServiceManager>, StatusCode>` — the
// error covers things like an unsupported service-manager protocol.
// Reaching it before `ProcessState::init_default()` is a programming error
// rather than a runtime condition, so that case panics instead, and every
// convenience wrapper in `hub` inherits both behaviors.
let sm = hub::default()?;

// Use methods on the ServiceManager instance
let service = sm.check_service("com.example.myservice");
let services = sm.list_services(hub::DUMP_FLAG_PRIORITY_ALL);
}

This is equivalent to using the free functions but allows you to pass the service manager as a parameter or store it in a struct.

Tips

  • ProcessState::init_default() comes first. Every hub:: function panics without it, including the ones whose signature cannot report failure.

  • Wait, do not poll. A client that starts before its service should call wait_for_interface / wait_for_service, which is event-driven when a binder thread pool is running, rather than looping on check_service. Use register_for_notifications when you need every (re-)registration over time — it, too, needs the thread pool.

  • Reach for the shell before writing a program. rsb_service list / info / check / dump manager answer most "is it registered, and who owns it" questions directly — see Inspecting a running HUB.

Storing Values (Data Serialization)

An AIDL definition is a schema, and the Rust that rsbinder-aidl generates from it is already a complete serializer. Until now it had nowhere to write except a transaction, so a project that wanted to save one of its own types reached for protobuf or serde and ended up maintaining a second description of the same data.

Two functions remove that second description:

#![allow(unused)]
fn main() {
let bytes = rsbinder::to_bytes(&settings)?;
std::fs::write("settings.bin", &bytes)?;

let settings: Settings = rsbinder::from_bytes(&std::fs::read("settings.bin")?)?;
}

Neither needs a feature. The encoder runs in the same session-less parcel mode the RPC transport uses, and the absence of a session is what refuses binders and file descriptors — so storage costs nothing to a build that only speaks kernel binder. The #[derive(rsbinder::Parcelable)] below does need macros, which is not on by default; a type generated from .aidl does not, since the generator emits the Parcelable impl directly.

Everything on this page is demonstrated end to end, against real .aidl definitions, by example-hello/src/bin/serde_demo.rs — it needs no binder device, no service manager and no socket:

$ cargo run -p example-hello --bin serde_demo

What you can store

Anything that implements Serialize works, but a type you intend to keep should be a parcelable. Nothing about the declaration is storage-specific — it is an ordinary .aidl file, compiled by an ordinary build.rs:

// aidl/settings/Settings.aidl
package settings;

@RustDerive(Clone=true, PartialEq=true)
parcelable Settings {
    String name = "unnamed";
    int volume = 50;
    String[] tags;
    @nullable String note;
}
#![allow(unused)]
fn main() {
// build.rs — the same call any interface gets.
rsbinder_aidl::Builder::new()
    .source(PathBuf::from("aidl/settings/Settings.aidl"))
    .output(PathBuf::from("settings.rs"))
    .generate()?;
}
#![allow(unused)]
fn main() {
use settings::Settings::Settings;

let value = Settings { name: "studio".into(), volume: 72, ..Default::default() };
std::fs::write("settings.bin", rsbinder::to_bytes(&value)?)?;

let restored: Settings = rsbinder::from_bytes(&std::fs::read("settings.bin")?)?;
}

The same type can still be sent through a transaction; storing it is just a second destination for the codec the generator already emitted.

If the type has no non-Rust consumer, the interface macros declare the same thing without a file or a build step — the emitted codec is byte-for-byte identical:

#![allow(unused)]
fn main() {
#[derive(rsbinder::Parcelable, Default, Debug, Clone, PartialEq)]
#[parcelable(descriptor = "settings.Settings")]
pub struct Settings {
    pub name: String,
    pub volume: i32,
    pub tags: Vec<String>,
    pub note: Option<String>,
}
}

Being a parcelable matters for a reason worth being explicit about. A parcelable writes a length header before its fields, and that header is the entire forward-compatibility story: a reader built against an older definition stops at the boundary the writer wrote, and a reader built against a newer one defaults the fields that were never written. So this works:

WriterReaderResult
v2 (two extra fields)v1v1's fields intact, the extras skipped
v1v2 (two extra fields)v1's fields intact, the extras default

rsbinder::to_bytes(&42i32) is four bytes with no header and no way to evolve. Wrap what you store in a parcelable.

The one rule for schema changes is the one every positional format has: append only. Reordering or removing a field changes what the bytes at each position mean, and nothing will tell you — the reader will decode whatever is there.

What you cannot store

Binders and file descriptors. Neither means anything outside the process that produced it: a binder handle is an index into that process's table, and an fd is an index into its descriptor table. Storing either would produce bytes that look valid and refer to nothing.

So a value containing one is refused at the point it is written — before any dup — rather than encoded:

#![allow(unused)]
fn main() {
// Err(StatusCode::FdsNotAllowed); the file descriptor is never duplicated.
let bytes = rsbinder::to_bytes(&value_with_an_fd)?;
}

A binder is BadType rather than FdsNotAllowed — the two conditions are distinct. FdsNotAllowed is what Android's libbinder returns for a session that permits no fds; the binder case has no AOSP counterpart, since there is no session-less parcel there to write one into.

The refusal runs in the other direction too. Bytes that merely look like a binder object are never turned into one: the decoder has no object table to resolve them against and returns an error instead of fabricating a reference. That property is what makes it safe to read a file you did not write.

Reading is strict

from_bytes requires the input to be consumed exactly:

InputResult
Ends inside the valueErr(NotEnoughData)
Decodes with bytes left overErr(BadValue)

Leftover bytes usually mean the file was written as a different type, and returning the partially-decoded value would hide that behind a plausible-looking wrong answer.

The bytes are the IPC bytes

There is no separate storage format. to_bytes produces exactly what a peer would receive for the same value over kernel binder or RPC — same codec, same layout — and since the wire is fixed little-endian, a value that carries no object is the same bytes on any architecture. A file written on an aarch64 phone reads on an x86_64 server.

The exception is a null binder. It is written as a 24-byte object header whose type word stays host-native, and it is recorded in no object table, so the refusals above do not catch it. It can only get in via a ParcelableHolder whose bytes were copied from a kernel parcel — but such a file is not portable between hosts of different endianness, and the value it came from does not read back even on the host that wrote it: decoding the payload returns BadType, because the decoder has no session to marshal a binder through.

What this buys you: a value can move between a file, a socket and a transaction without being re-encoded, and one schema describes all three.

What this is not

It is not a protobuf replacement. The format is positional and untagged, strings are UTF-16 (roughly double the bytes of UTF-8), and everything is padded to a 4-byte boundary. There is no self-describing type information, so nothing can tell you that a file holds a Settings rather than a Preferences with a compatible prefix — that is your framing to add if you need it.

It is for dropping a type you have already described in AIDL onto disk, and it is very good at that.

RPC Transport (binder-over-socket)

rsbinder ships two parallel Binder stacks:

  1. Kernel binder — the traditional path through /dev/binder / /dev/binderfs/binder, the rsb_hub or Android servicemanager, and the kernel binder driver. This is what every chapter so far has covered.
  2. RPC transport — binder-over-socket, AOSP's RpcServer / RpcSession equivalent in pure Rust. No kernel binder, no ProcessState, no service manager. A socket (Unix-domain, vsock, or TLS over TCP) carries the same Parcel payload 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 rpc Cargo feature, off by default. Builds without rpc carry zero RPC code and zero extra dependencies. Enable per crate:

[dependencies]
rsbinder = { version = "0.12", features = ["rpc"] }

When to use RPC vs. kernel binder

NeedStack
Two processes on the same Linux/Android hostKernel binder
Pure user-space, no kernel binder driverRPC (Unix)
Cross-host / cross-VM binder callsRPC (vsock/TLS)
Runs on macOS as the host platformRPC only
Wants Android's getCallingUid() / SELinux for freeKernel binder
Needs custom transport (TLS, mTLS, …)RPC

Kernel binder is faster (single ioctl per transaction, shared memory) and gives you Android's security model for free. RPC trades some of that for portability and reach — it works on Linux, Android, and macOS, with no kernel driver and no service manager.

A minimal RPC service and client

The complete example lives under example-hello. Once you've enabled the rpc feature on the workspace, run:

# Terminal 1
$ cargo run -p example-hello --features rpc --bin rpc_hello_service

# Terminal 2
$ cargo run -p example-hello --features rpc --bin rpc_hello_client

Server

use rsbinder::rpc::RpcServer;
use rsbinder::*;
use example_hello::*;

const RPC_SOCKET: &str = "/tmp/rsb_hello_rpc.sock";

struct IHelloService;
impl Interface for IHelloService {}

impl IHello for IHelloService {
    fn echo(&self, echo: &str) -> rsbinder::BinderResult<String> {
        Ok(echo.to_owned())
    }
}

fn main() -> std::result::Result<(), Box<dyn std::error::Error>> {
    // No ProcessState, no hub. RPC never touches the kernel binder.
    let _ = std::fs::remove_file(RPC_SOCKET);
    let server = RpcServer::setup_unix_server(RPC_SOCKET)?;

    // Publish a root binder. Clients fetch this via get_root().
    server.set_root(BnHello::new_binder(IHelloService {}).as_binder())?;

    // Accept loop runs on this thread until stop_accepting().
    server.run()?;
    Ok(())
}

Key points:

  • No ProcessState::init_default() — the RPC stack is independent of the kernel binder singleton. Mixing the two stacks in one process is fine (the Accessor pattern below relies on it), but a pure-RPC process needs neither ProcessState nor rsb_hub.
  • set_root publishes the single root binder. The client fetches it back through RpcSession::get_root — this is the moral equivalent of hub::wait_for_interface(name) for kernel binder, just without a service manager.
  • server.run() runs the accept loop until [RpcServer::stop_accepting] is called from another thread. Use [RpcServer::run_background] to spawn it on a dedicated thread instead. stop_accepting only raises the flag — sessions already connected drain as their peers leave. [RpcServer::terminate] ends those too (every session closed, every worker joined); it is what dropping a ServerGuard does. See How a connection ends.

Client

use rsbinder::rpc::RpcSession;
use rsbinder::{FromIBinder, Strong};
use example_hello::*;

const RPC_SOCKET: &str = "/tmp/rsb_hello_rpc.sock";

fn main() -> std::result::Result<(), Box<dyn std::error::Error>> {
    let session = RpcSession::setup_unix_client(RPC_SOCKET)?;
    let root = session.get_root()?;

    // Same generated stub as the kernel path — try_from picks the
    // RPC proxy under the hood.
    let hello: Strong<dyn IHello> =
        <dyn IHello as FromIBinder>::try_from(root)?;

    let reply = hello.echo("Hello over RPC!")?;
    println!("server replied {reply:?}");
    Ok(())
}

The generated Bp* proxies dispatch through as_remote(), which returns either the kernel handle or the RPC proxy depending on what the SIBinder came from. One AIDL stub, two stacks — there is no separate "RPC client API" you need to learn.

Multiple named services on one server

RpcServer::set_root publishes a single root binder. If you need to expose multiple named services on the same socket — the moral equivalent of the kernel-binder hub::add_service(name, …) flow — use [RpcServer::add_service] instead. The first call automatically turns the root into a built-in service directory, so clients reach each service by name through [RpcSession::get_service]:

#![allow(unused)]
fn main() {
// Server
let server = RpcServer::setup_unix_server(SOCKET)?;
server.add_service("hello", BnHello::new_binder(IHelloService).as_binder())?;
server.add_service("echo",  BnEcho::new_binder(IEchoService).as_binder())?;
server.run()?;

// Client
let session = RpcSession::setup_unix_client(SOCKET)?;
let hello: Strong<dyn IHello> =
    <dyn IHello as FromIBinder>::try_from(session.get_service("hello")?)?;
let echo: Strong<dyn IEcho> =
    <dyn IEcho  as FromIBinder>::try_from(session.get_service("echo")?)?;
}

add_service is O(1) per call: the directory is built once and shares the server's service map, so later registrations — even after the server is already serving — are seen through the same root with no rebuild. Mixing set_root with add_service on the same server is not supported (the last one installed wins); pick one publishing model per server.

For registering and looking up services with the same code over kernel binder or RPC, see Cross-Transport Services.

Wire-protocol profiles

rsbinder speaks two RPC wire profiles, chosen at connect time:

ProfileDefault?Constructor
Android 12 (r34)yesRpcSession::setup_unix_client(path)
Android 13–16opt-inRpcSession::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_versionAOSP version
0Android 13
1Android 14 / 15
2Android 16

The server picks the highest version both sides advertise:

#![allow(unused)]
fn main() {
// Server: offer up to android-16 wire v2.
let server = RpcServer::setup_unix_server("/tmp/foo.sock")?;
server.set_android13plus(2);
server.set_root(my_root)?;

// Client: offer up to v2. Negotiation picks min(client, server).
let session = RpcSession::setup_unix_client_android13plus(
    "/tmp/foo.sock", 2,
)?;
}

The android-13+ profile has been validated end-to-end against real AOSP libbinder on Android 13/14/15/16 emulators (full parcel-body transact, byte-correct), so an rsbinder RPC server can serve a real Android libbinder client and vice versa.

The Android 12 (r34) profile

The default profile speaks the wire of Android 12 and 12L (android-12.0.0_r1 through r34, SDK 31 and 32), whose RPC code did not change across those releases. It has no handshake:

  • A client opens each connection by writing an int32 session id: -1 for a new session. After that every message is a 16-byte RpcWireHeader and the body it announces, with no length prefix.
  • Every binder in a parcel, a null one included, is followed by its stability as Android 12 writes it (0x0c000001 for the default Stability::System, 0x00000001 for null), whatever the host's SDK.
  • An RpcServer mints an id for each new session. An Android 12 client reads GET_MAX_THREADS and GET_SESSION_ID, then opens max_threads - 1 more connections that write that id; the server serves each as its own connection of the session, so calls on different connections run at once. A local connection joins only from the founding connection's uid, a check Android 12 does not make. RpcSession::get_session_id returns the id's 4 bytes.
  • An rsbinder r34 client opens one connection and does not join more.

Android 12 libbinder differs from what rsbinder offers on this wire:

Android 12 libbinderrsbinder r34
Largest message body100,000 bytes (kMaxTransactionAllocation): it refuses to send more (NO_MEMORY). A server connection that receives more closes; a client waiting for a larger reply gets NO_MEMORY with the body left unread, and its next call on that connection ends its sessionMAX_FRAME_LEN (64 MiB)
TLSnoner34 inside TLS, between rsbinder peers only
Incoming (callback) connectionsnonenone
File descriptorsrefused (BAD_TYPE)negotiate_fd_transport, between rsbinder peers only; against Android 12 it returns None
A session endswhen its last connection doeswhen any one of its connections does

Keep a parcel exchanged with Android 12 under 100,000 bytes. The last row matters only when one of a session's connections fails on its own: an Android 12 client opens and closes them together.

0.12.0 changed this wire. Releases before it framed every r34 message with a u32 length and wrote no session id or stability, so they cannot talk to Android 12 libbinder, and a 0.12.0 peer and an older one refuse each other's first bytes. Upgrade both ends together. A custom RpcTransport must implement send_raw / recv_raw; see the CHANGELOG.

This profile has been validated against real Android 12 libbinder on an SDK 31 emulator, both ways: an Android 12 client against an rsbinder RpcServer with three connections per session (parallel calls, oneway order, nested callbacks, null binders, reference counts, the size limit), and an rsbinder client against an Android 12 RpcServer (example-hello/cpp/run_rpc_r34_interop.sh).

Transports

RpcSession/RpcServer are transport-agnostic. The bundled backends are:

BackendFeature flagTrust boundary
Unix socketrpc (always on)Filesystem perms + SO_PEERCRED/getpeereid
vsockrpc-vsockHypervisor VM isolation (host ↔ VM)
TLS / TCPrpc-tlsTLS certificate chain (caller-owned rustls)
Plain TCPrpc-tcp-debugNone — debug/interop only, never production

Add the matching feature in Cargo.toml:

[dependencies]
rsbinder = { version = "0.12", features = ["rpc", "rpc-vsock"] }

Each backend implements the RpcTransport trait — you can implement your own if you need a custom carrier. A session moves its bytes through send_raw / recv_raw on both wire profiles, so a transport of your own implements those; the trait's send_frame / recv_frame are a separate length-prefixed API no session calls.

Abstract Unix-domain sockets (Linux/Android)

Since 0.10.0 the Unix backend can also bind the Linux abstract socket namespace — a kernel-namespace name with no filesystem entry, so there is no socket file to create, unlink, or leak:

#![allow(unused)]
fn main() {
// Server: no filesystem path, nothing to clean up on shutdown.
let server = RpcServer::setup_unix_server_abstract(b"my.rpc.name")?;

// Client, r34 wire:
let session = RpcSession::setup_unix_client_abstract(b"my.rpc.name")?;

// Client, android-13+ wire (negotiating up to v2):
let session =
    RpcSession::setup_unix_client_android13plus_abstract(b"my.rpc.name", 2)?;
}

These functions exist only on Linux and Android (the abstract namespace is a Linux kernel feature).

For the android-13+ client there is also a builder, RpcClientConfig, consumed by RpcSession::setup_client_android13plus_with_config. It has one constructor per transport — unix, unix_abstract, vsock, tls, tcp_debug, and new for a connect function of your own — and the same knobs on all of them: an outgoing-connection fan-out, incoming (callback) connections, and the fd transport mode:

#![allow(unused)]
fn main() {
use rsbinder::rpc::{FileDescriptorTransportMode, RpcClientConfig, RpcSession};

let session = RpcSession::setup_client_android13plus_with_config(
    RpcClientConfig::unix_abstract(b"my.rpc.name", 2)
        .outgoing_connections(2)
        .fd_mode(FileDescriptorTransportMode::Unix),
)?;
}

The Unix-only predecessor RpcUnixClientConfig, and the setup_unix_client_android13plus_{with_config,with_id,fan_out} helpers, are deprecated since 0.12.0 and will be removed in the release after it.

The builder's session_id knob is for the manual attach calls, add_outgoing_connection_with_config and add_incoming_connection_with_config, which add one connection to a session you already hold. Every entry that builds a new RpcSession — setup_client_android13plus_with_config, the deprecated setup_unix_client_android13plus_with_id and connect_android13plus_fd_with_id, and the entry API's deprecated ClientOptions::session_id — refuses a non-empty id with BadValue. A second client RpcSession on one server session would keep its own oneway numbering, binder addresses and lifetime: the server would drop one side's oneway calls as out of order, could dispatch a call meant for one side's binder to the other side's binder at the same address, and would end the shared session when either side closed. AOSP has no public entry for this either: RpcSession::setupClient is private, and the connections that echo the id belong to the session that read it. For the same reason a manual attach accepts only the id of the session it is called on: any other id, such as one read from another RpcSession, is BadValue before connecting.

The id you echo must come from RpcSession::get_session_id() on the session you attach to — one round trip that asks the server for it, kept by the session. RpcSession::session_id() is a different method: on a client session it returns a client-local value that the server has never seen. Both return 32 opaque bytes, so nothing but the method name distinguishes them; the attach calls refuse every id but the session's own with BadValue before connecting. An attach the server refuses (for example, more connections than its set_max_threads allows) fails at the attach call, and on add_outgoing_connection_with_config that failure also ends the session it was attaching to (see How a connection ends); stay within negotiate() connections.

Security. An abstract socket has no filesystem permissions: any process in the same network namespace can connect (subject only to LSM policy such as SELinux), so the directory-mode access control a path-bound socket can rely on does not apply. When exposure matters, install an [RpcServer::set_authorizer] hook (see Security) and check the peer identity (uid/gid/pid) it receives.

TLS client

#![allow(unused)]
fn main() {
use rsbinder::rpc::rustls;
use rsbinder::{Client, Endpoint};
use std::{sync::Arc, time::Duration};

let mut roots = rustls::RootCertStore::empty();
// ... populate `roots` from your trust anchors ...
let config = Arc::new(
    rustls::ClientConfig::builder()
        .with_root_certificates(roots)
        .with_no_client_auth(),
);

// The certificate is verified against the host name unless
// `tls_server_name` names another.
let client = Client::open_with(Endpoint::tls("binder.example.com", 9999), |o, _| {
    o.tls = Some(config);
    o.timeout = Some(Duration::from_secs(10));
})?;
let root = client.session().expect("an RPC client").get_root()?;
}

timeout bounds each step of connecting — connect(2) across every address the host name resolves to, the TLS handshake, and on ?profile=android13plus the android-13+ handshake — and then the session's reply waits (Timeouts). Without it, a server that accepts the connection and then sends nothing hangs open_with. RpcSession::setup_tcp_client_tls and setup_tcp_client_tls_android13plus take no deadline at all; RpcClientConfig::tls(..).timeout(d) is the bounded form of the second.

rsbinder::rpc::rustls is the exact rustls version the rpc-tls backend links against, re-exported so you don't have to track it in your own Cargo.toml.

TLS server

The server side takes a rustls::ServerConfig carrying its certificate chain and private key. RpcServer::setup_tcp_server_tls runs the TLS handshake on each connection's own worker thread (so a slow-handshake peer stalls only its worker, never the accept loop), then serves the same session as any other backend:

#![allow(unused)]
fn main() {
use rsbinder::rpc::{rustls, RpcServer};
use rustls::pki_types::pem::PemObject;
use rustls::pki_types::{CertificateDer, PrivateKeyDer};
use std::sync::Arc;

// Server certificate chain + private key, loaded from PEM.
let certs: Vec<_> =
    CertificateDer::pem_slice_iter(SERVER_CERT_PEM.as_bytes()).collect::<Result<_, _>>()?;
let key = PrivateKeyDer::from_pem_slice(SERVER_KEY_PEM.as_bytes())?;

let config = Arc::new(
    rustls::ServerConfig::builder()
        .with_no_client_auth()              // one-way TLS — see "Who needs a key?"
        .with_single_cert(certs, key)?,
);

// TCP is TLS-only by design: there is no plaintext-TCP server constructor.
let server = RpcServer::setup_tcp_server_tls("0.0.0.0:9999", config)?;
server.set_root(my_root_binder)?;
server.run()?;                              // or server.run_background()
}

RpcServer::setup_unix_server_tls and (with rpc-vsock) RpcServer::setup_vsock_server_tls do the same over a Unix socket / vsock — TLS is orthogonal to the socket kind (it mirrors AOSP's RpcTransportCtx::newTransport(fd)).

Who needs a key?

ModeServerClient
One-way TLS (default)cert chain + private key (always)trusted roots only — no key
mTLS (mutual)cert chain + private key and a client-cert verifiercert 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 its PeerIdentity::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(), @EnforcePermission over 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 treat Anonymous as deny.

Use [RpcServer::set_authorizer] to enforce a per-connection policy. The closure runs once on accept, before any RPC byte is exchanged:

#![allow(unused)]
fn main() {
const ALLOWED_UID: u32 = 1000;

server.set_authorizer(|peer| {
    peer.uid() == Some(ALLOWED_UID)
});
}

A rejected peer's socket is closed; its next operation sees DeadObject. The hook is opt-in — without it, every connection is accepted (matching the prior behaviour).

set_authorizer is connection-level (decided once, at handshake) — the right granularity for vsock and TLS, which carry no per-call uid. For per-method authorization, a handler can also read the caller directly: over a Unix-domain RPC connection rsbinder::get_calling_uid() returns the kernel-vouched peer uid (and calling_caller() the full transport-tagged identity), exactly as on kernel binder — so a uid ACL written once runs on both. Over a uid-less transport get_calling_uid() is the fail-closed u32::MAX sentinel. See Security & Authorization.

Capabilities

The RPC stack is feature-complete for the everyday cases you'd use kernel binder for, with a few extras specific to socket transport:

  • AIDL interfaces — every type the AIDL compiler supports works over RPC: primitives, strings, arrays, parcelables, nested interfaces, enums, unions, oneway methods.
  • Callbacks (nested binders) — a callback object created on the client crosses the socket like any other Binder and the server invokes it back through the same session. A twoway call from inside a handler always works; a oneway call, or a call from any other server thread, needs the client to open an incoming connection — see Callbacks outside a handler.
  • ParcelFileDescriptor — opt in on both ends: the server with RpcServer::set_supported_fd_modes, the client with RpcClientConfig::fd_mode (the fd_mode connect option of the entry API). On the android-14+ wire the mode rides the connection header and is fixed for the session's lifetime, as in AOSP. A server that has not opted in refuses a client that requests Unix, as AOSP does: it closes the connection after the handshake, so the client's first call fails with DeadObject. On that wire RpcSession::negotiate_fd_transport sends nothing, and asking it for a Unix mode the header did not agree is StatusCode::InvalidOperation. An android-13 (v0) header carries no fd mode, so such a session passes no fds. negotiate_fd_transport negotiates only on the r34 wire, which has no fd mode in AOSP, as an rsbinder-only exchange after connect; against AOSP r34 libbinder it returns None. File descriptors ride out-of-band over SCM_RIGHTS on Unix-domain sockets.
  • Death notifications — link a DeathRecipient on the proxy as usual. A session that ends — its peer closes, any one of its connections fails, or a deadline expires (see Timeouts) — fires every linked recipient (the RPC analogue of "the remote process died").
  • Async — the same into_async::<Tokio>() adapter that wraps a blocking kernel-binder proxy works over RPC. See Async Service — for RPC the only difference is how you obtain the proxy.

Sessions with multiple connections

RpcServer::set_max_threads(N) matches AOSP's setMaxIncomingThreads. Both N == 1 (the default — one connection per session, the mode every example in the book uses) and N >= 2 (multi-connection sessions) are validated against real Android 13–16 libbinder peers. On the r34 wire N is how many connections an Android 12 client opens (The Android 12 (r34) profile). See the rustdoc on RpcServer::set_max_threads for the per-mode details.

set_max_threads caps incoming slots per session. To cap server-wide concurrent connections — for example to bound the worker-thread fan-out regardless of how many sessions a single client opens — use RpcServer::set_max_connections(N) (default: unlimited). Both knobs are independent and additive.

Callbacks outside a handler

A server can always make a twoway call to a client's callback while it is answering that client — the nested call rides the connection the request came in on. Calling it from anywhere else (a timer, a worker thread, a oneway notification fired later) needs a connection the server can send on, and by default a session has none: the server fails such a call at once with WouldBlock (AOSP WOULD_BLOCK) rather than waiting for a slot that will never free up.

A oneway call needs that connection even from inside the handler. It never rides the connection the request came in on, as in AOSP libbinder ("asynchronous calls cannot be nested"): the server does not wait for it and goes on to write its reply, while the client may still be running the oneway inside its own reply wait — and a twoway call the client's handler makes back on that connection would then read the server's reply as its own.

The client provides that connection, exactly as libbinder's ARpcSession_setMaxIncomingThreads(n) does:

#![allow(unused)]
fn main() {
use rsbinder::rpc::{RpcClientConfig, RpcSession};

let session = RpcSession::setup_client_android13plus_with_config(
    RpcClientConfig::unix(std::path::Path::new(RPC_SOCKET), 2).incoming_connections(1),
)?;
}

or, through the unified entry, Client::open_with(uri, |o, _| { o.incoming_connections = Some(1) }) on a Uri with WireProfile::Android13Plus (?profile=android13plus). This works on every RPC transport — unix://, vsock:// and tls:// alike. Each incoming connection is a further connection to the same endpoint (over tls://, its own TLS session), attached to the same session and served by a thread the session owns; n of them let n server threads call back in parallel. For a transport the entry does not name — TLS over a Unix socket, say — build the connections yourself with RpcClientConfig::new(version, connect), whose connect is called once per connection. The server budgets them at 2 × set_max_threads per session (two on a default server) and refuses the rest, which the client sees as a setup error.

Two consequences worth knowing:

  • Death can be observed at all. The serving thread sees the server's connection drop and fires the linked death recipients immediately. A client session without incoming connections has nobody reading its connection, so link_to_death is refused there with InvalidOperation (AOSP refuses it the same way). The other way to get notified is RpcSession::spawn_serve, which dedicates the founding connection to a serve loop: the session then makes no calls of its own, only nested ones from inside a handler.
  • Stop the session explicitly. The serving threads keep the session alive; dropping the RpcSession handle and every proxy does not end them. Call RpcSession::close_session() when you are done — it shuts the connections down and joins the threads.

This needs the android-13+ profile (the attach echoes the session id). Streaming is the first thing in rsbinder itself that depends on it: a producer pushes batches from a thread of its own, so Sink::open checks for TransportCaps::CALLBACKS and refuses a session that has no incoming connection.

How a connection ends

A session ends as a whole, never one connection at a time. As in AOSP libbinder (RpcState::handleRpcError), a send or receive that fails on any of its connections shuts the session down: every connection closes, calls in flight on the others fail with DeadObject — whether the peer ran them is unknown — every linked death recipient fires, and every local object the peer held is released. A single connection cannot be resumed: the wire has no frame numbering or acknowledgement that would tell which oneway call or reference-count frame the lost connection took with it, and a session that carried on without them would leave an object's later oneway calls queued behind a number that never arrives. Reconnecting, fetching the root again and registering callbacks again are the application's; Reconnecting does all three. A connection whose handshake fails never joins the session. A server's callback connection whose connection-init write fails is dropped alone, since no frame rode it and the client's attach fails with it. A client's attach, in either direction, ends the session once the server may hold the connection: any failure after its header went out, except, for a callback attach, a close (a reset included) before the first byte of the server's "cci"; see add_incoming_connection_with_config. An outgoing attach has no such exception, because libbinder android-16.0.0_r3 and later end their session when they refuse one at the setMaxThreads cap, and the close that refusal produces looks the same as any other; see add_outgoing_connection_with_config. A client's callback connection whose serving thread cannot be started ends the session too, since the server already holds it as a callback connection.

A serve loop — RpcSession::serve_blocking and its variants — returns rpc::SessionEnd, not Result<()>. It answers three questions a status code cannot hold at once:

  • stream — is the stream still at a frame boundary (StreamState::InSync), or is its position lost (Lost: a cut frame, a TLS stream that ended without close_notify, a deadline that expired part-way through a frame)?
  • by — did this end decide (EndedBy::Local: close_session, terminate, an idle deadline this end armed), or not (NotLocal)?
  • reason — what happened (EndReason), for logs.

SessionEnd::into_result() is the one projection back to a Result: Ok(()) for an intact stream, Err(DeadObject) for a lost one. Only stream decides it; by is attribution.

Each call on the ending side means one thing:

calldoes
RpcSession::close_session()declare the session dead: every connection's transport is shut down, cached proxies get binder_died, the session's incoming threads are joined
RpcServer::stop_accepting()raise the accept flag; connected sessions drain as their peers leave; nothing is joined
RpcServer::terminate()stop accepting, close every session the server minted, join the workers
ServerGuard::stop_and_join()terminate, after joining the accept thread — the same as dropping the guard
RpcTransport::shutdown()the transport half-close itself (shutdown(2) in both directions): wakes a reader blocked on this end, fails this end's later sends

What this end's own reader sees after its shutdown is platform-dependent — Linux delivers frames already queued ahead of the end of stream, macOS discards them — so code must not assume a queued frame arrives, or that it does not. The peer reads the end of stream on either platform; what differs is the peer's next send (Linux AF_UNIX: EPIPE at once; macOS: accepted and discarded; TCP: a reset on a later write).

Timeouts

AOSP libbinder's RPC path has no timeouts: every socket wait is a poll without a bound, and only a session shutdown ends it. Every deadline below is an rsbinder addition, and each measures one thing.

SettingWhat it boundsOn expiry
RpcSession::set_timeout(d); RpcClientConfig::timeout, ClientOptions::timeout; on a server RpcServer::set_reply_timeout, ServeOptions::reply_timeout, applied to every session it makesHow long the peer may leave this end without an answer: a reply wait; a send that makes no progress (SO_SNDTIMEO, every bundled socket transport; a transport of your own only if it implements RpcTransport::set_write_timeout; on Linux and Android progress is the peer taking this end's bytes, SIOCOUTQ, which a reply wait counts too, and Apple counts the bytes a send accepted: transport module doc "A slow peer"); the peer's host not answering (the kernel's keepalive check, TCP and TLS over TCP); on a client, each connect and handshake step (connect(2) itself only for tcp_debug and tls, where it is one step across every address the host resolves to: a unix connect waits while the listener's accept queue is full, and the kernel bounds a vsock one by its own connect timeout)The session ends; the call that waited returns TimedOut. A connect or handshake step: the setup or attach call returns TimedOut, and an attach whose step expires after its header went out ends the session (How a connection ends)
The same d, for a free connection in the session's poolA wait for a connection to send on; nothing has been sent yetThat call returns WouldBlock; the session goes on
RpcServer::set_handshake_timeout, ServeOptions::handshake_timeout (default 10 s)An accepted connection from its accept to admission, its TLS handshake and first contact included, as a whole: a peer that sends its handshake a byte at a time does not stretch it. The set_authorizer hook's run is not counted. A transport whose RpcTransport::shutdown_handle is None, the default for one of your own, is bounded per wait only, by the set_read_timeout and set_write_timeout it implements (neither: no bound)The connection is dropped
RpcServer::set_idle_timeout, ServeOptions::idle_timeoutA session in which no byte crossed any connection and no call was open, judged per session (set_idle_timeout states the timing and what counts)The session ends

Set the session timeout above the slowest legitimate handler. It is how long the peer may go without answering before it counts as broken, not a budget for one call: a call that times out takes the session with it, because the reply that is late may still come and nothing could tell it apart from the next one. A caller that wants to give up on one call and keep the session runs the call on another thread, or as a future, and stops waiting for it. There is no default: a library cannot know how long a handler may legitimately run, and neither kernel binder nor libbinder bounds it.

Keepalive is on for every TCP connection, TLS over TCP included. Without a session timeout it runs at the system's intervals, so a session whose peer host vanished ends, and fires its death recipients, after hours instead of never; set_timeout(d) sizes the check to d. The values, and what Linux, Android and macOS each do with them, are stated once, in RpcTransport::set_liveness. The probes are between the two kernels and change no byte on the wire, so a libbinder peer needs nothing. Unix sockets and vsock, TLS over them included, have no such check.

A relay hides a break. Keepalive reaches only the first TCP endpoint on the path. Behind a relay that terminates TCP — adb forward, ssh -L, a TLS terminator — the relay answers the probes, and a break past it shows only when a call waits for a reply and the session timeout expires. A stream that waits has no call outstanding, so it pings its peer instead (Streaming).

On a server the reply timeout is also the liveness bound. A server that never calls its clients back has no reply to wait for, so the reply timeout reaches its clients only as keepalive and the send bound. A server that wants a vanished client's session cleaned up sooner than the system's keepalive sets set_reply_timeout for that.

The idle timeout is for untrusted peers. A peer that finishes the handshake and then stays silent holds a worker thread and, under set_max_connections, an admission slot; keepalive does not catch it (its kernel answers) and neither does the reply timeout (it asks for nothing). The idle timeout is for a server that admits unauthenticated TCP or TLS peers and caps them; one that picks its peers with set_authorizer or TLS client authentication needs it less. What counts as idle, including a client that only waits for callbacks, is stated once, in set_idle_timeout.

RpcClientConfig::handshake_timeout and ClientOptions::handshake_timeout are deprecated: the client timeout bounds connecting. A value still set there takes precedence for the handshake.

Bridging RPC and the service manager: the Accessor pattern

Android 16 introduced IAccessor — a kernel-binder interface whose sole job is to hand a client a connected RPC socket fd. The client asks the system service manager for the accessor (a kernel-binder service), calls addConnection(), and the returned ParcelFileDescriptor is the RPC socket the client then drives through the android-13+ handshake.

rsbinder implements both sides of this pattern:

  • Consume side — the shared hub lookup path (hub::check_service, hub::wait_for_service, and the typed/try_* variants) transparently follows the IAccessor arm: if the service manager hands back an IAccessor instead of a regular binder, rsbinder calls addConnection(), adopts the fd, runs the v2 handshake, and gives you back the RPC root. Your client code looks identical to a regular hub::check_service call.

  • Register side — hub::android_16::create_accessor(instance, addr_provider) builds a LocalAccessor BnAccessor you can publish via hub::add_service. The provider closure resolves an instance name to an AccessorSockAddr (Unix(path), UnixAbstract(name) — added in 0.10.0 — Vsock { cid, port }, or Inet(addr)), and the accessor opens the connection on demand. The enum is intentionally exhaustive (not #[non_exhaustive]), so downstream exhaustive matches need an arm for the new UnixAbstract variant — see the 0.10.0 CHANGELOG:

    #![allow(unused)]
    fn main() {
    use rsbinder::hub::{self, android_16::{create_accessor, AccessorSockAddr}};
    use rsbinder::rpc::RpcServer;
    use rsbinder::ProcessState;
    use std::path::PathBuf;
    
    // 0. The kernel-binder side needs ProcessState to publish the
    //    accessor through the system service manager. The RPC server
    //    itself does NOT (it runs entirely in user space).
    ProcessState::init_default()?;
    ProcessState::start_thread_pool();
    
    // 1. Run a regular RPC server on a UDS.
    let sock = PathBuf::from("/data/local/tmp/my.sock");
    let server = RpcServer::setup_unix_server(&sock)?;
    server.set_android13plus(2);
    server.set_root(my_root)?;
    let _bg = server.run_background();
    
    // 2. Vend an IAccessor that hands clients an fd connected to
    //    `sock`, and publish it through the kernel service manager.
    let path = sock.clone();
    let accessor = create_accessor("my.service", Box::new(move |_name| {
        Ok(AccessorSockAddr::Unix(path.clone()))
    }));
    hub::add_service("my.service", accessor)?;
    
    // 3. Block on the kernel binder thread pool so the accessor stays
    //    reachable for the lifetime of the process.
    ProcessState::join_thread_pool()?;
    }

Use hub::android_16::add_accessor_provider when you want a process-local accessor — one that doesn't go through the system service manager. Lookups via hub::check_service / hub::wait_for_service in the same process fall back to the process-local registry when the service manager returns nothing.

Both sides of the Accessor pattern have been validated against real Android 16 libbinder on the emulator.

The Accessor solves one direction — a kernel client reaching an RPC service. For the reverse, and for the rule that binder objects never cross between the two stacks, see Cross-Transport Services § Bridging two transports in one process.

Async over RPC

The blocking I/O the RPC stack uses is deliberate — it matches the AOSP RpcServer/RpcSession model, where each connection has its own pair of blocking threads. The standard spawn_blocking/block_on adapters that already wrap kernel-binder async work for RPC too:

#![allow(unused)]
fn main() {
use rsbinder::Tokio;

let session = RpcSession::setup_unix_client(RPC_SOCKET)?;
let root = session.get_root()?;
let hello: Strong<dyn IHello> =
    <dyn IHello as FromIBinder>::try_from(root)?;

// Convert the blocking proxy into the async one.
let async_hello = hello.into_async::<Tokio>();

let reply = async_hello.echo("hi").await?;
}

For services, Bn*::new_async_binder(impl, TokioRuntime(handle)) works exactly like it does on the kernel path. See Async Service.

There is intentionally no non-blocking RpcTransport / reactor-based async serve loop: async support is these adapters over the blocking stack, not a second I/O engine underneath it.

Platform support

PlatformKernel binderRPC
LinuxYes (with binderfs)Yes (Unix, vsock, TLS)
AndroidYes (built in)Yes
macOSNoYes (Unix, TLS — for development & cross-stack interop testing)
WindowsNoNo (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>() and new_async_binder patterns 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::rpc module on docs.rs/rsbinder for the full API surface (every public function, struct, and trait introduced above is documented in detail there).

Cross-Transport Services

The AIDL interface, the generated Bp*/Bn* stubs, your service impl, and the call sites are already transport-agnostic — the same impl IFoo runs unchanged over kernel binder or RPC. What differs is only the bootstrap: kernel binder uses ProcessState + the hub service manager, while RPC uses RpcServer / RpcSession.

rsbinder::serve and rsbinder::connect make that bootstrap transport-agnostic too: the destination is a Uri value, and everything else is the same three calls. The low-level APIs remain the direct path and are unchanged; this is a thin layer over them with no wire change.

Three lines per side

#![allow(unused)]
fn main() {
use rsbinder::*;

// Server — kernel binder: ProcessState init + thread pool + service
// manager registration + join. The same code with Endpoint::unix("/tmp/x.sock")
// binds a socket, publishes the service, and runs the accept loop.
rsbinder::serve(Uri::kernel())?
    .add("hello", BnHello::new_binder(MyService))?
    .run()?;

// Client — open, look up, cast. The proxy alone keeps an RPC session
// alive; there is nothing else to hold.
let hello: Strong<dyn IHello> = rsbinder::connect(Uri::kernel().with_service("hello"))?;
//  rsbinder::connect(Endpoint::unix("/tmp/x.sock").with_service("hello"))?
hello.echo("hi")?;
}

Destinations: Uri

A Uri is an Endpoint (where), an optional service name (what to look up), and the RPC wire to speak. serve, connect, Client::open and Reconnecting::builder take anything that converts into one: a Uri, a &Uri, an Endpoint, or a KernelEndpoint.

EndpointConstructorNotes
kernel binderUri::kernel(), Endpoint::kernel(), KernelEndpoint::default().with_driver(..).with_threads(..).with_mmap_size(..)settings apply to the process-wide ProcessState
Unix socketEndpoint::unix(path)absolute path; any bytes the OS accepts
abstract Unix socketEndpoint::unix_abstract(name)Linux/Android
vsockEndpoint::vsock(cid, port)feature rpc-vsock
TLS over TCPEndpoint::tls(host, port)feature rpc-tls (TCP is TLS-only)
  • endpoint.with_service("hello") (or uri.with_service(..)) names the service. serve() ignores it, so a server and its clients can share one value; connect() requires it; Client::open() rejects it.
  • uri.with_wire(WireProfile::Android13Plus(WireVersion::MAX)) selects the AOSP versioned wire on an RPC endpoint; the default WireProfile::R34 speaks the r34 wire. A kernel endpoint takes only R34.
  • KernelEndpoint::with_mmap_size sizes the mapping this process receives transactions into — the real meaning of the "1 MB binder limit". See Receive mapping size.
  • KernelEndpoint, WireProfile, WireVersion, UriError and UriErrorKind live in rsbinder::entry.

String form

Configuration files and command lines carry the same value as a string. Parse it explicitly — the entry functions take no &str:

#![allow(unused)]
fn main() {
let uri: Uri = config.target.parse()?;      // or Uri::parse(&config.target)?
let hello: Strong<dyn IHello> = rsbinder::connect(uri)?;
}
binder://[<service>][?driver=<path>&threads=<n>&mmap=<bytes>]
unix://<abs-path>[#<service>]               (three slashes: unix:///tmp/x.sock)
unix-abstract://<name>[#<service>]          Linux/Android
vsock://<cid>:<port>[#<service>]            feature rpc-vsock
tls://<host>:<port>[#<service>]             feature rpc-tls; IPv6 host in [ ]
  • The service name is the #fragment; binder://name is a shorthand for binder://#name.
  • ?profile=android13plus[-vN] on any RPC scheme is WireProfile::Android13Plus (WireVersion::MAX when -vN is omitted).
  • Paths, names, hosts and the service are percent-decoded, so a path with #, ?, % or bytes that are not UTF-8 is written escaped.
  • An unknown scheme or query key, a key given twice, an empty #, or binder://a#b is a UriError; uri_error.kind() says which. UriError converts into StatusCode::BadValue for ? (logging the reason) and implements std::error::Error for Box<dyn Error>.
  • uri.to_string() writes the string form back. Parsing it gives the same Uri, for every value serve / connect accept — so build URIs with the constructors, not with format!.

Server

serve(uri) returns a Server:

add(name, svc)publish svc (anything Into<SIBinder>, e.g. BnFoo::new_binder(..)). Queued; run / spawn register it after checking the options (kernel: with the service manager, once the thread pool is up; RPC: in the server's directory).
with(|o| ..)set ServeOptions
run()serve and block. Kernel: start the thread pool and join it (never returns normally). RPC: the accept loop, until shut down.
spawn()serve in the background; returns a ServerGuard. RPC: dropping the guard (or ServerGuard::stop_and_join()) ends the server — every session is closed and the threads are joined. Kernel: the guard is inert (the process thread pool has no shutdown).

Kernel serve(Uri::kernel()) initializes the process-wide ProcessState idempotently; a second kernel server in the same process reuses it, and is refused with StatusCode::BadValue if it asked for a different driver, thread count, or mapping size — the process cannot give it one. The refusal lands where the option is read: serve() itself for the KernelEndpoint settings (?driver= / ?threads= / ?mmap=), run/spawn for ServeOptions::threads and ServeOptions::mmap_size, and open for ClientOptions::driver and ClientOptions::mmap_size. RPC servers bind their listener at run/spawn, so options (TLS config, limits) set via with apply first.

Client

connect(uri) is Client::open(endpoint)?.get(name). Keep a Client only to issue more lookups on the same endpoint:

#![allow(unused)]
fn main() {
let c = rsbinder::Client::open(Endpoint::unix("/run/app.sock"))?;
let hello: Strong<dyn IHello> = c.get("hello")?;
let stats: Strong<dyn IStats> = c.get("stats")?;
drop(c);                                  // proxies stay valid
}
get::<T>(name)kernel: waits for registration (AOSP waitForService); RPC: one directory lookup, NameNotFound if absent
try_get::<T>(name)non-waiting; Ok(None) when absent
binder(name)untyped SIBinder
session()the underlying RpcSession (None on kernel binder)
endpoint()the Endpoint (mirrors Server::endpoint())

Client::open(Uri::kernel()) also starts the binder thread pool, so callbacks, death notifications, and registration waits are delivered promptly.

Options

Anything that is not part of the Uri goes through ServeOptions / ClientOptions:

#![allow(unused)]
fn main() {
rsbinder::serve(Endpoint::tls("0.0.0.0", 9000))?
    .with(|o| {
        o.tls = Some(server_tls_config);          // rustls::ServerConfig (feature rpc-tls)
        o.max_connections = Some(64);
        o.authorizer = Some(Box::new(|id| id.uid() == Some(1000)));
    })
    .add("hello", BnHello::new_binder(MyService))?
    .run()?;

let hello: Strong<dyn IHello> = rsbinder::Client::open_with(Endpoint::tls("host", 9000), |o, _endpoint| {
    o.tls = Some(client_tls_config);
})?
.get("hello")?;
}

ServeOptions: threads, mmap_size, max_connections, handshake_timeout, idle_timeout, reply_timeout, authorizer, tls, fd_modes, call_restriction. ClientOptions: tls / tls_server_name, session_id (deprecated: a non-empty id is refused), outgoing_connections, incoming_connections, fd_mode, timeout, handshake_timeout (deprecated: timeout bounds connecting), driver, mmap_size. What each timeout bounds, and what its expiry ends, is in Timeouts.

Receive mapping size

A binder transaction is copied into a buffer the driver allocates out of the receiving process's mapping, so that mapping — not the protocol — is what bounds a call. rsbinder maps the same ~1 MB AOSP libbinder does; raise it on a service that must accept larger calls:

#![allow(unused)]
fn main() {
use rsbinder::entry::KernelEndpoint;

rsbinder::serve(KernelEndpoint::default().with_mmap_size(4 << 20))?   // 4 MB, the driver's ceiling
    .add("bulk", BnBulk::new_binder(MyService))?
    .run()?;
}
  • Bytes only (no 4M shorthand), between one page and MAX_BINDER_MMAP_SIZE (4 MB, where the driver clamps silently — a larger request is refused here rather than quietly shrunk), rounded up to a page. A one-page mapping is legal and carries a call of a few KB.
  • Set on the receiver. A caller needs nothing; a payload too large for the destination comes back as StatusCode::FailedTransaction. A client is a receiver of its own replies, which is what ClientOptions::mmap_size is for.
  • Oneway transactions may use only half of it — the driver reserves the rest so an async flood cannot starve synchronous calls.
  • Only address space is reserved; pages are faulted in as they are used.
  • Process-wide and fixed at the first serve / Client::open, like the driver and thread count. ProcessState::init_with_mmap_size is the direct form, and ProcessState::mmap_size() reports what is in force.
  • ServeOptions::mmap_size cannot make the mapping: serve initializes ProcessState before the option is read, so the field can only agree with the size already in force and a different one is BadValue at run/spawn — the same rule as ServeOptions::threads. What sets the size on a kernel server is KernelEndpoint::with_mmap_size (binder://?mmap=), or ProcessState::init_with_mmap_size called before serve.

Client::open_with's closure also receives the Endpoint, so an option that applies to only some transports can be set from it — useful when the Uri was parsed from configuration:

#![allow(unused)]
fn main() {
let client = rsbinder::Client::open_with(&uri, |o, endpoint| {
    if endpoint.supports_fd_passing() {
        o.fd_mode = Some(FileDescriptorTransportMode::Unix);
    }
})?;
}

An option that does not apply to the transport (a TLS config on kernel binder, call_restriction on a Unix socket) is BadValue at run/spawn/open time, with a log line naming the option — never silently ignored. A kernel setting that does apply but the process cannot honor — a thread count or driver against a ProcessState already initialized with another value — is refused the same way, at serve() for the KernelEndpoint form. So is a Uri whose string form could not carry it: a relative Unix path, an empty abstract name, TLS host or service name, or a kernel endpoint with WireProfile::Android13Plus. Users who want that checked at compile time use the low-level types directly.

Bridging two transports in one process

Everything above moves one service onto whichever transport you pick. A different problem is reaching a service that lives on the other transport — a socket client that needs a kernel service, or the reverse. rsbinder has three answers, and which one applies depends on the direction:

DirectionMechanismWhere
kernel client → socket serviceAccessor (IAccessor, Android 16 / rsb_hub) — the service manager hands the client a connection to the socketRPC Transport § Bridging
one service, both transportsdual-hosting — the same Bn* passed to two serve(...).add(...) callsthis chapter
socket client → kernel servicegateway — a process that re-publishes an upstream proxy as a local servicebelow

None of the three moves raw bytes between the stacks. A binder that belongs to one stack cannot be written into a parcel of the other: rsbinder refuses it at write time with InvalidOperation, exactly as libbinder does (Parcel::flattenBinder, RpcState::onBinderLeaving). There is no transparent bridge, and building one is a non-goal — a generic byte forwarder would silently change the caller identity, fd rights, stability and oneway ordering that the two stacks define differently.

The gateway

A gateway process B connects to the upstream service C and publishes it again on its own endpoint. Because Bn*::new_binder accepts anything implementing the interface, and a typed handle implements the interface it points at, that is one line:

#![allow(unused)]
fn main() {
// B: reach C over kernel binder, re-publish it on a Unix socket.
let upstream: Strong<dyn IHello> = rsbinder::connect(Uri::kernel().with_service("my.hello"))?;

rsbinder::serve(Endpoint::unix("/tmp/rsb_gw.sock"))?
    .add("hello", BnHello::new_binder(upstream))?   // <- the gateway
    .run()?;
}

Passing the proxy instead — .add("hello", upstream.as_binder()) — is the mistake this refuses (InvalidOperation at add). Wrapping is not ceremony: the Bn* is a local binder that B owns, and that is what makes it publishable.

The rule: every binder object crossing B is unwrapped by B and wrapped again. Plain data flows through untouched. So a method with a binder argument — a callback registration, say — needs B to override just that method and re-wrap the argument before forwarding it:

#![allow(unused)]
fn main() {
fn register(&self, cb: &Strong<dyn ICallback>) -> BinderResult<()> {
    self.upstream.register(&BnCallback::new_binder(cb.clone()))
}
}

Forwarding cb as-is fails with InvalidOperation, reported back to the original caller.

That inline new_binder is correct for one call and wrong across calls: each one mints a new object, so register(cb) and unregister(cb) reach C as two unrelated binders and the removal matches nothing. bridge::Rewrap is the table that fixes it — same remote in, same local out:

#![allow(unused)]
fn main() {
struct Gateway {
    upstream: Strong<dyn IFoo>,
    callbacks: Rewrap<dyn ICallback>,   // Rewrap::new(BnCallback::new_binder)
}

fn register(&self, cb: &Strong<dyn ICallback>) -> BinderResult<()> {
    self.upstream.register(&self.callbacks.wrap(cb))
}
fn unregister(&self, cb: &Strong<dyn ICallback>) -> BinderResult<()> {
    self.upstream.unregister(&self.callbacks.wrap(cb))   // the same object
}
}

It holds only weak references, so it never keeps a wrapper alive: the wrapper lives as long as C holds it, and the entry goes when the remote dies or the wrapper is dropped.

What a gateway costs, all of it visible in B:

  • Identity. C sees B as its caller, not A. C's @EnforcePermission checks pass with B's credentials, so authenticating A is B's job — ServeOptions::authorizer on B's endpoint.
  • File descriptors. They only cross a Unix-domain link, and only with FD passing negotiated on that hop.
  • Uid. Over TCP or vsock, B cannot learn A's uid at all.
  • Plaintext TCP is bring-up only; a real deployment uses TLS or a Unix socket.
  • Death. There are now two lifetimes. A's proxy dies when B goes away; B's upstream dies when C does. B does not forward one as the other.
  • Threads. Each forwarded call occupies one of B's RPC workers for the whole upstream round trip — size ServeOptions::threads accordingly.

example-hello ships a runnable one:

cargo run -p example-hello --bin hello_service                 # C, on kernel binder
cargo run -p example-hello --features rpc --bin gateway_service \
    binder://my.hello unix:///tmp/rsb_gw.sock                  # B
cargo run -p example-hello --features rpc --bin unified_client \
    unix:///tmp/rsb_gw.sock                                    # A

What the Uri does not hide

Moving a service between transports changes its trust boundary — read Security & Authorization.

binder://RPC schemes
@EnforcePermissionenforced via the system permission servicedenied (no caller-uid model) — authorize with ServeOptions::authorizer
get_calling_uid()kernel-vouchedkernel-vouched on unix://, fail-closed sentinel elsewhere
deathprocess deathsession disconnect
list_services, notifications, lazy servicesvia hubnot available
abandoning a sessionnothing to docall RpcSession::close_session() if a service you exported holds a proxy back into it (client.session())

Transport capabilities

The differences above are enforced where they happen: writing a file descriptor into a parcel that cannot carry one fails at the write, and get_calling_uid() returns a sentinel that no ACL matches. Those answers arrive at the moment of use, which is late if you were about to promise a caller something the transport cannot do.

TransportCaps is the same information available up front. Ask before you commit:

use rsbinder::TransportCaps;

let client = rsbinder::Client::open(rsbinder::Endpoint::unix("/run/app.sock"))?;
// Fails here, logging which bits are missing and when each one holds,
// instead of on the first callback the server tries to make.
client.caps().require(TransportCaps::CALLBACKS, "event subscription")?;

Read it from Client::caps(), RpcSession::caps(), Endpoint::static_caps(), or — inside a handler, for the call being served — calling_caps().

binder://unix://vsock://tls://
FD_PASSING✅after negotiating Unix fd mode❌❌
TRUSTED_UID✅✅❌❌
CALLBACKS✅with incoming_connections > 0with incoming_connections > 0with incoming_connections > 0
SAME_HOST✅✅❌❌
KERNEL_KNOBS✅❌❌❌

Three things to keep straight.

Caps never replace the checks they summarize. Skipping the check is not less safe; the fd write still refuses, the uid is still a sentinel. What you lose is being told early, and being told when the missing bit holds.

Endpoint::static_caps() and the session's caps answer different questions. The endpoint answers for the transport family; the session says what it has. The endpoint bounds the session in neither direction: a unix:// endpoint reports FD_PASSING, but a session over it carries no descriptors until both ends negotiate the Unix fd mode — the default is None; and no RPC endpoint reports CALLBACKS (the binder:// column does, because kernel binder has every bit), although a session opened with incoming_connections > 0 does. The table's two qualified cells are session values for that reason, not endpoint ones.

A session's caps are a snapshot. CALLBACKS goes away when the last callback connection dies. Read them where you act, not once at startup.

Async

Under the tokio feature, rsbinder::connect_async::<dyn IHelloAsync<Tokio>>(uri).await runs the connect on spawn_blocking. Async servers pass BnHello::new_async_binder(impl, TokioRuntime(handle)) to add and use spawn() (never run()) from inside a runtime — see Async Service.

Worked example

example-hello ships bin/unified_service.rs / bin/unified_client.rs: identical service, registration, and call code with the transport chosen by the argument (kernel, rpc, or any URI string, parsed into a Uri).

cargo run -p example-hello --features rpc --bin unified_service rpc
cargo run -p example-hello --features rpc --bin unified_client rpc
cargo run -p example-hello --features rpc --bin unified_client unix:///tmp/rsb_unified.sock

Security & Authorization

A service handler often needs to answer "who is calling, and are they allowed to?" rsbinder gives you the caller's identity and several ways to authorize — but the right tool differs by transport, and that difference is deliberately made explicit rather than hidden.

Core principle. Kernel binder and RPC have genuinely different trust boundaries. Kernel binder gives you a kernel-vouched uid/pid and SELinux context; RPC's trust boundary is the transport itself (Unix peer credentials, a TLS certificate, or hypervisor VM isolation). rsbinder never papers over this: an authorization check that is safe on kernel binder either keeps working, fails closed, or is denied over RPC — never silently weakened.

Caller identity inside a handler

The calling context is ambient thread-local state, set by the dispatch machinery just before your handler runs and cleared just after (this mirrors AOSP's IPCThreadState). So inside a transact handler you read it with no parameter threading:

#![allow(unused)]
fn main() {
impl IExample for MyService {
    fn do_thing(&self, arg: i32) -> rsbinder::BinderResult<()> {
        let uid = rsbinder::get_calling_uid();   // who is calling, right now
        // ... authorize, then act ...
        Ok(())
    }
}
}

The accessors (all rsbinder::…):

AccessorReturnsWorks over
get_calling_uid()caller effective uidkernel binder, Unix RPC
get_calling_pid()caller pidkernel binder, Unix RPC
get_calling_sid()SELinux context (Option<CString>)kernel binder only (RPC → None)
is_handling_transaction()boolkernel binder, RPC
calling_caller()Option<Caller> (transport-tagged)kernel binder, RPC

These are only meaningful inside a handler, on the dispatching thread. Outside a transaction get_calling_uid() / get_calling_pid() return this process's own uid and pid (getuid() / getpid()), get_calling_sid() returns None and calling_caller() returns None — as AOSP's IPCThreadState does. A thread you spawn inside a handler has no calling context, so it reads this process, not the caller: do the check on the dispatching thread and pass the result along. An in-process call to a local service reads the same way — this process calling itself.

get_calling_sid() is Some only for a binder built with BinderFeatures::set_requesting_sid (or the context manager), and only where SELinux is available: on Android, or on a Linux host with /sys/fs/selinux/enforce. Elsewhere rsbinder does not ask the kernel for the context at all — a kernel with no LSM that can produce one fails every transaction to a binder that asks — and the sid is None.

Fail-closed values, never fabricated ones

get_calling_uid() returns a real uid only where the transport carries one — kernel binder, and Unix-domain RPC (SO_PEERCRED on Linux/Android, getpeereid on macOS/BSD). Over a transport with no uid (vsock, TLS certificate, anonymous) it returns the sentinel u32::MAX, which is never 0 (root) and never a real privileged uid:

#![allow(unused)]
fn main() {
// Over vsock / TLS / anonymous, get_calling_uid() == u32::MAX:
if rsbinder::get_calling_uid() == 0 {      // u32::MAX != 0  ⇒ never matches
    // ... "allow root" ...                // so a uid-less peer can't slip through
}
if rsbinder::get_calling_uid() != AID_SYSTEM {
    return Err(Status::from(ExceptionCode::Security));  // uid-less peer ⇒ denied
}
}

This is intentional: fabricating a uid (say 0) for a transport that doesn't have one would be a privilege-escalation hole. uid ACLs simply fail closed over uid-less transports — use a transport-appropriate check there instead (see below).

The transport-tagged caller

For anything beyond a plain uid check, match on Caller, which tags the identity by transport so you authorize explicitly:

#![allow(unused)]
fn main() {
use rsbinder::{Caller, ExceptionCode, Status};
use rsbinder::rpc::PeerIdentity;

fn authorize() -> rsbinder::BinderResult<()> {
    match rsbinder::calling_caller() {
        // Kernel binder: Android permission, uid, or SELinux `sid`.
        Some(Caller::Kernel { uid, .. }) if uid == 1000 => Ok(()),
        // Unix RPC: kernel-vouched peer uid.
        Some(Caller::Rpc(PeerIdentity::Local { uid, .. })) if uid == 1000 => Ok(()),
        // TLS RPC: certificate subject allowlist.
        Some(Caller::Rpc(PeerIdentity::Certificate(cert)))
            if cert.subject() == "CN=trusted-client" => Ok(()),
        // vsock cid, anonymous, no transaction, or a future variant:
        // fail closed.
        _ => Err(Status::from(ExceptionCode::Security)),
    }
}
}

The arm is the transport of the transaction being served now, not of the one that started the thread. An RPC handler that makes an outgoing kernel call can have a kernel transaction dispatched inside it (binder's nested IPC); inside that inner handler calling_caller() is Caller::Kernel, and the uid, pid and sid getters report the kernel caller, until the inner transaction returns.

Caller and PeerIdentity are #[non_exhaustive], so the compiler forces a catch-all arm — which nudges you toward a fail-closed default.

Identity stops at a bridge

A gateway or Accessor re-publishes an upstream service on another transport, and the caller identity does not travel with the call. In A → B → C, C's handler sees B — B's uid, B's certificate, B's sid. C's @EnforcePermission checks therefore pass with B's credentials, and a uid allowlist in C authorizes B, not A.

That makes B the trust boundary. If C is meant to be reachable only by certain callers, B has to enforce it, on B's own endpoint — via ServeOptions::authorizer, or a calling_caller() check in the forwarding method. A gateway that forwards unconditionally widens C's exposure to everyone who can reach B, which over TCP or vsock is everyone who can reach the port, since B cannot learn A's uid there at all.

Four ways to authorize

WhenUse
uid check, common to kernel + Unix RPCget_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 authzPermissionAuthority (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::Kernel arm 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/sid and calling_caller() — ambient thread-local state, valid only inside a handler.
  • uid ACLs work on kernel binder and Unix RPC, and fail closed (u32::MAX sentinel) on uid-less transports.
  • @EnforcePermission is kernel-only and denies over RPC.
  • Use match calling_caller() for transport-aware rules, set_authorizer for connection-level RPC policy, and PermissionAuthority to 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.

TierVersioningWire compatibilityWhen it can change
StableSemver-strictAOSP-faithful, byte-tested against real libbinderOnly on a major bump — and pre-1.0 the 0.x minor slot is the major slot (see the changelog preamble). Breaking changes are last-resort.
ProvisionalStabilizing toward 1.0AOSP-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.
ExperimentalOpt-in via Cargo featureNo guarantee until the corresponding real-libbinder interop gate passesAnytime, including the wire format. Default builds do not reach this code.

Stable

Surface that has shipped through multiple releases against real Android binder and is not expected to break.

  • Kernel binder core — Parcel, IBinder, Interface, Strong<I>, Weak<I>, Status, StatusCode, ParcelFileDescriptor, ProcessState::{init, init_default, start_thread_pool, join_thread_pool}, Binder::new, BinderFeatures.
  • Transaction code constants — FIRST_CALL_TRANSACTION, LAST_CALL_TRANSACTION, PING_TRANSACTION, INTERFACE_TRANSACTION, DUMP_TRANSACTION, FLAG_ONEWAY, FLAG_CLEAR_BUF.
  • Service manager (Android binder) — hub::add_service, hub::check_service. (hub::get_service / hub::get_interface were deprecated in 0.10.0 and removed in 0.11.0, in favor of wait_for_* / check_interface / try_* — which are Provisional (below). See Service Manager.)
  • AIDL compiler entry points — rsbinder_aidl::Builder::{new, source, output, version, generate}.
  • rsb_device binary CLI (binderfs setup); --group / --mode and the 0600 default node are new in 0.11.0.

Provisional

Implemented and validated, but the public-API shape is still under review for 1.0. Expect at most a single round of renames or signature tweaks; wire formats are already locked.

  • Service-manager lookups (new in 0.10.0) — hub::wait_for_service, wait_for_interface, check_interface, try_get_service, try_get_interface, register_client_callback, try_unregister_service, and the impl Into<SIBinder> form of add_service. Wire behavior matches AOSP waitForService / checkService; only the Rust signatures are still settling.
  • RPC transport — RpcServer, RpcSession, setup_unix_server, setup_unix_client*, from_preconnected_fd, set_root, get_root, set_android13plus, set_max_threads(N) (both N == 1 and N >= 2 multi-connection), set_max_connections, set_supported_fd_modes(&[FileDescriptorTransportMode::Unix]). Validated against real android-13 / 14 / 15 / 16 libbinder (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 — addService descriptor auto-detect, getService2, checkService2, Service::Accessor routing. Linux-native bringup. New in 0.11.0: per-name access control, service declarations, on-demand start, dump, supervisor integration, and the rsb_service CLI — the configuration file format is settling with them.
  • macOS first-class support (plan 2-9 Phase A+B).
  • Entry API (new in 0.11.0) — serve, connect, connect_async, Client, Server, ServerGuard, ServeOptions, ClientOptions, Endpoint. Replaced the rsbinder::service facade. The URI grammar and the two option structs are what may still change.
  • Interface macros (new in 0.11.0, macros feature) — #[rsbinder::interface], #[derive(Parcelable)], #[derive(BinderEnum)]. Feature-gated but not Experimental: the emitted code is byte-for-byte the .aidl code, so the wire is whatever .aidl already guarantees. Only the Rust-facing spelling is provisional; 0.12.0 narrowed it to what .aidl renders and added #[deprecated]. See Interface Macros.
  • Shared memory (new in 0.11.0) — SharedMemory, MemoryHeapBase, MappedHeap, MemoryDealer, HeapCache, and the IMemory / IMemoryHeap wire (handwritten AOSP wire, not AIDL). STAGE3-gated against real libbinder in both directions.
  • Data serialization (new in 0.11.0) — to_bytes / from_bytes, no feature required since 0.12.0. The bytes are the IPC bytes, so only the two signatures may still change. See Storing Values.
  • Cross-stack bridging (new in 0.11.0) — impl Interface for Strong<I> (a proxy publishes as a local service, so a gateway is one line) and bridge::Rewrap. See Cross-Transport Services.
  • lazy_service (new in 0.11.0) — LazyServiceRegistrar and the AOSP registerService / client-callback lifecycle, gated against Android's own servicemanager.
  • Receive mapping size (new in 0.12.0) — ProcessState::init_with_mmap_size, binder://?mmap=, ClientOptions::mmap_size / ServeOptions::mmap_size. The size is local to the receiving process and nothing about it crosses the wire.
  • TransportCaps (new in 0.12.0) — Client::caps(), RpcSession::caps(), Endpoint::static_caps(), calling_caps(). Each bit is derived from a check another type still performs, so the set of bits is what may grow.
  • Cancellation (new in 0.12.0) — the cancel module over AOSP's android.os.ICancellationSignal, STAGE3-gated against real libbinder in both directions.
  • Typed service-specific errors (new in 0.12.0) — ServiceSpecificError, #[derive(ServiceSpecificError)], impl_service_specific_error!, Status::service_specific, Status::service_error, Status::message. The code on the wire is the i32 AOSP writes; only the Rust mapping is new.
  • Large payloads and pipes (new in 0.12.0) — Parcel::write_blob / read_blob (AOSP's blob wire, inline up to 16 KB and a memfd region above), ParcelFileDescriptor::pipe() and its Read / Write impls.
  • Awaitable lookups and death (new in 0.12.0, tokio feature) — wait_for_interface_async, check_interface_async, death_signal / DeathSignal. Wire behavior is that of the synchronous calls they wrap.
  • Fast Message Queue (new in 0.12.0) — the rsbinder-fmq crate (MessageQueue, EventFlag, Descriptor, AttachPolicy; the synchronized flavor, primitive elements, Linux and Android) and rsbinder::fmq, which re-exports it on every platform beside the AOSP parcelables MQDescriptor, GrantorDescriptor, SynchronizedReadWrite, UnsynchronizedWrite and NativeHandle, with the two TryFrom conversions between MQDescriptor and Descriptor. rsbinder-aidl resolves imports of android.hardware.common and android.hardware.common.fmq to those types and compiles generic parcelables such as MQDescriptor<T, Flavor>. The parcelables are AOSP's VINTF-stable wire, and the queue keeps libfmq's memory layout, counters and futex protocol, so only the Rust signatures may still change. For an NDK peer, which cannot link libfmq, the reference implementation is two header-only C11 files: rsbinder-fmq/c/rsbinder_fmq.h (the queue) and contrib/ndk/rsbinder_stream.h (the stream's records on it). Validated against AOSP's own libfmq on the host, both roles (rsbinder-fmq/tests/libfmq_host.rs), and across binder on an emulator with either side as the service (example-hello/cpp/run_fmq_interop.sh); the queue header against the crate on the host (rsbinder-fmq/tests/c_header.rs), the record header with both ends under AddressSanitizer (tests/tests/c_stream_header.rs), and both against rsbinder on an emulator (example-hello/cpp/run_stream_interop.sh). See Builtin AOSP Types and Generic Parcelables.
  • Streaming (new in 0.12.0) — the stream module: Sink, Receiver, and the three AIDL files in rsbinder/aidl/stream/: the rsbinder.stream.StreamEndpoint<T> parcelable (T the item type; the ring's record layout and EventFlag bits on the kernel path) and the two interfaces IStreamSink / IStreamSource (the RPC path). The contract is rsbinder's own rather than AOSP's, written as ordinary AIDL so a C++ or NDK peer can be either end (a Java peer is out of scope: AOSP has no Java FMQ). Validated rsbinder-to-rsbinder over kernel binder (two processes, including a real Android driver) and over RPC, and over kernel binder against an NDK peer built on the C reference headers and against one whose ring is AOSP's own libfmq, both directions (example-hello/cpp/run_stream_interop.sh), and over RPC against the device's libbinder RpcSession, both directions (example-hello/cpp/run_stream_rpc_interop.sh). See Streaming.
  • Observability (new in 0.12.0) — the work source functions (set_calling_work_source_uid and the five beside it, the AOSP IPCThreadState set), Remotable::transaction_name with rsbinder_aidl::Builder::trace, the observe module (TransactionObserver, set_observer, LogObserver, StatsObserver), and the tracing feature (TracingObserver and the client spans of proxies generated with trace(true)). None of it changes the wire: the work source uses a header field kernel binder already has. The work source is STAGE3-gated against real libbinder. See Observability.
  • Reconnecting (new in 0.12.0) — Reconnecting and the reconnect module: one service named by a Uri, looked up again after its process dies or its RPC session ends, with an on_connect hook rerun on each new connection. It adds nothing to the wire. On kernel binder it sends, beyond a plain connect, a death link and, while the name is absent, a service-manager notification registration, both existing commands; over RPC it sends nothing a plain connect does not. Validated over kernel binder and RPC, and against a real libbinder RpcServer that is killed and restarted on an emulator (example-hello/cpp/run_rpc_reconnect_interop.sh). See Reconnecting to a Service.
  • Freeze notifications (new in 0.12.0) — IBinder::add_frozen_state_change_callback / remove_frozen_state_change_callback, FrozenStateChangeCallback, FrozenState, and ProcessState::freeze_process / process_freeze_info: AOSP addFrozenStateChangeCallback (libbinder android-15.0.0_r6+) over the driver's own commands, so the contract is the kernel UAPI rather than a libbinder wire. Validated on the Rust binder driver, on the C driver of an Android 17 emulator, and on an Android 15 driver without features/freeze_notification, where add_frozen_state_change_callback returns InvalidOperation (tests/tests/freeze_kernel.rs).
  • RpcClientConfig (new in 0.12.0) — one android-13+ client configuration for every transport (unix, unix_abstract, vsock, tls, tcp_debug, new), with incoming_connections and outgoing_connections on all of them. It replaces RpcUnixClientConfig and the Unix-only multi-connection helpers, which are deprecated and go in the release after.
  • AIDL compiler, beyond the Stable entry points — Builder::{dest_dir, hash} and the public render module, the seam rsbinder-macros plugs into. Its input structs are #[non_exhaustive], so a template gaining a field stays a minor release.

Experimental (opt-in)

Off by default, gated by a Cargo feature. Wire format and API may change without a deprecation cycle.

FeatureSurfaceStatus
rpc-tcp-debugTcpDebugTransport — plain TCP backendBring-up / interop only, never production.
rpc-vsockVsockTransport — host↔VMLinux/Android only; loopback testing requires vsock_loopback.ko.
rpc-tlsTLS 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 reject Map<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 Java Parcel's runtime type-tag system (VAL_STRING, VAL_INTEGER, VAL_MAP, …) which has no typed-Rust analogue. The rsbinder-aidl frontend therefore emits unknown type 'Map' for any Map<…> reference — matching AOSP. Use parcelable (typed struct), List<Entry>, or ParcelableHolder instead. Re-examine if AOSP removes the cross-backend block.

What this means in practice

  • A 0.x → 0.(x+1) minor bump can change Provisional signatures. Pre-1.0 the minor slot is the major slot, so a Stable item can change there too — what the tier buys you is that it is last-resort and always lands in that release's Migrating from … section of the CHANGELOG, named with its replacement. That section carries the changes that have a replacement to name; one that only alters a value or a behavior may be recorded under Fixed instead. Changed / Removed carry the rationale for the entries that have one, but not every entry appears there. rsbinder runs cargo-semver-checks for both rsbinder and rsbinder-aidl (default features and RPC features) on pull requests targeting master; it reports signature changes, and cannot see a change that alters only a value or a behavior. Neither can your build — for those, read Migrating and Fixed both.
  • Experimental features never break the default build's wire format. Enabling one is an explicit acknowledgement that the corresponding interop gate has not passed.
  • After 1.0, all Stable items follow Semver strictly. Provisional items get re-classified as Stable or moved into a feature gate.

Migrating from 0.11 to 0.12

Every change in 0.12.0 that can break a program built against 0.11.0: a compile error, a different value on the wire, or behavior a running program can observe. Entries are grouped by area; within an area, those that change the wire say so and need both peers upgraded together.

The additions and fixes themselves are listed in CHANGELOG.md; a reference such as "(CHANGELOG Fixed)" points to the 0.12.0 section there.

Toolchain and features

  • Minimum supported Rust version is now 1.86 (was 1.85). The local-binder cast Binder::<B>::try_from(SIBinder) (behind FromIBinder::try_from and SIBinder::into_interface) now downcasts the Arc through trait upcasting instead of reinterpreting a raw pointer, which needs 1.86. Results are unchanged: a remote proxy, another Remotable type, or an IBinder wrapper that forwards as_any() to a local binder still fails with BadValue.
  • Android builds must link for API 29 (Android 10, the oldest supported platform) or newer: pass --platform 29 to cargo ndk. rsbinder now calls bionic's process_vm_readv, which cargo-ndk's default API 21 does not export, so a build without it fails with undefined symbol: process_vm_readv. 0.11.0 linked at API 21.
  • The tokio feature no longer enables tokio/full, only tokio/rt, tokio/rt-multi-thread and tokio/sync (for death_signal). A crate that received tokio/net, time, signal, fs, io-util or macros through rsbinder must declare them in its own Cargo.toml.
  • BinderAsyncRuntime for TokioRuntime<Runtime> and for TokioRuntime<Arc<Runtime>> are gone (an owned runtime in a binder panics in Runtime::drop when the last Strong drops on a worker). Wrap runtime.handle().clone() in TokioRuntime<Handle> instead.

Status codes

  • StatusCode::from(ExceptionCode::ServiceSpecific) is ServiceSpecific(0), not Ok, as in AOSP; StatusCode::from(status) for such a Status now yields ServiceSpecific(0), not FailedTransaction.
  • On a host whose errno numbering is not Linux asm-generic (Apple platforms, and Linux on SPARC, MIPS, Alpha or PA-RISC) the status codes on the wire changed; upgrade every such peer together. 0.12.0 sends AOSP's values (utils/Errors.h in Linux asm-generic errno numbering) on every host (CHANGELOG Fixed). Between a 0.11.0 and a 0.12.0 macOS peer six named codes decode differently: a 0.11.0 peer reads 0.12.0's InvalidOperation, UnknownTransaction, BadIndex, NotEnoughData, WouldBlock and TimedOut as Errno values (NotEnoughData's -61 as Darwin ECONNREFUSED), and a 0.12.0 peer reads 0.11.0's as Unknown. A StatusCode::Errno no longer crosses between two such peers either: it arrives as Unknown. The same holds for a StatusCode serialized into a Parcel. Between two peers with asm-generic numbering (Linux and Android on every other architecture) these codes do not change.
  • On a host whose errno numbering is not Linux asm-generic StatusCode::from(i32) and i32::from(StatusCode) convert wire values, not host errnos. StatusCode::from(-libc::ECONNREFUSED) is NotEnoughData on macOS, since -61 is AOSP NOT_ENOUGH_DATA; StatusCode::from(-libc::ECONNRESET) is Unknown, and i32::from(StatusCode::Errno(e)) is UNKNOWN_ERROR (i32::MIN). Build a status from an errno with From<rustix::io::Errno> or From<std::io::Error>, which keep StatusCode::Errno in the host's numbering. On a host with asm-generic numbering both conversions are unchanged apart from the sign rule in the next entry.
  • A handler's Err(StatusCode::Errno(x)) with x >= 0, or Err(StatusCode::ServiceSpecific(v)) with v <= 0, now answers Unknown on every host (CHANGELOG Fixed), where it answered success (0) or a status the client decoded as another variant. i32::from and a StatusCode written into a Parcel follow the same rule. Send a zero or negative service-specific code inside a Status (Status::new_service_specific_error, Status::service_specific), which carries it unchanged.
  • ExceptionCode::from(StatusCode::UnexpectedNull) is TransactionFailed, not NullPointer, as AOSP Status::setFromStatusT maps every non-zero status_t. A handler returning Err(StatusCode::UnexpectedNull) now answers with that transaction status instead of an EX_NULL_POINTER exception header: the client's Status has exception_code() == TransactionFailed and transaction_error() == UnexpectedNull, and a branch on ExceptionCode::NullPointer no longer runs.
  • A service handler's Err(StatusCode::DeadObject) reaches a remote caller as FailedTransaction, over kernel binder and over RPC; every other status goes out as i32::from encodes it. An AOSP BpBinder that receives DEAD_OBJECT as a reply status marks itself dead (mAlive = 0, BpBinder::transact) and fails every later call on that proxy without sending it, so one handler error cut the caller off from a service that was still running. DeadObject reaches a handler as an ordinary value: ? on a nested call to a dead binder, or on a BrokenPipe write error (EPIPE is DEAD_OBJECT), as in an on_dump whose reader exited (dumpsys foo | head). The substitution also covers a Status with ExceptionCode::TransactionFailed and DeadObject as its transaction error, which a generated stub sends as the reply status. A local (in-process) call still returns the handler's Err unchanged; a client that matched DeadObject from a live rsbinder service now sees FailedTransaction.
  • FLAG_COLLECT_NOTED_APP_OPS is 0x2, not 0x80 (CHANGELOG Fixed): a flag value sent on the wire changes, so a 0.11.0 peer testing or setting it disagrees with a 0.12.0 one; rebuild both sides.

Entry API (serve / connect)

  • serve, connect, connect_binder, connect_async, Client::open and Client::open_with take impl Into<Uri>, not &str (CHANGELOG Changed). Build the destination with constructors, or parse a string explicitly:

    #![allow(unused)]
    fn main() {
    // 0.11.0
    rsbinder::serve("binder://")?;
    let h: Strong<dyn IHello> = rsbinder::connect("unix:///tmp/x.sock#hello")?;
    let h: Strong<dyn IHello> = rsbinder::connect(&config.target)?;
    
    // 0.12.0
    rsbinder::serve(Uri::kernel())?;
    let h: Strong<dyn IHello> = rsbinder::connect(Endpoint::unix("/tmp/x.sock").with_service("hello"))?;
    let h: Strong<dyn IHello> = rsbinder::connect(config.target.parse::<Uri>()?)?;
    }

    The schemes and query keys are the same, but a malformed string now fails at parse with a UriError instead of at serve / connect; ? into rsbinder::Result still yields StatusCode::BadValue and logs the reason. Replace a URI built with format!("unix://{}", path.display()) by Endpoint::unix(&path): the string form is percent-decoded, so a path holding %, # or ? named a different socket or failed to parse.

  • Endpoint changed shape. Endpoint::Kernel(KernelEndpoint) replaces the 0.11.0 struct variant Kernel { driver, threads }; read the settings with KernelEndpoint::driver() / threads() / mmap_size() and build one with KernelEndpoint::default().with_threads(..). Endpoint::Vsock { cid, port } and Endpoint::Tls { host, port } replace the tuple variants; build them with Endpoint::vsock(cid, port) and Endpoint::tls(host, port).

  • Uri's fields are private: use endpoint(), service() and wire(). wire_max_version: Option<u32> is now a WireProfile — R34, or Android13Plus(WireVersion) for ?profile=android13plus[-vN]. rsbinder::entry::uri::parse is removed; use Uri::parse or str::parse.

  • A tls:// host is percent-decoded, its [ ], if any, must be one pair enclosing the whole host, and a host holding : needs them. 0.11.0 took the host text literally, split it from the port at the last : and stripped any number of brackets from its ends: tls://[[::1]]:9000, tls://[::1:9000 and tls://::1:9000 reached ::1, tls://a]:9000 reached a, and tls://fe80::1, with no port, became host fe80: and port 1. All of these are now UriErrorKind::BadBrackets; write tls://[::1]:9000. Write an IPv6 zone id as %25 (tls://[fe80::1%25eth0]:9000): tls://[fe80::1%eth0]:9000 is now BadPercentEscape, and a numeric zone such as %12 decodes to a control character instead of reaching the resolver. A client connecting to a host with a zone id must also set ClientOptions::tls_server_name: the host is the default TLS server name, a server name cannot hold a zone id, and without it the connect fails with StatusCode::RpcError. tls://[]:9000, which 0.11.0 took as an empty host, is EmptyHost.

  • A query key given twice is refused (UriErrorKind::DuplicateQueryKey). 0.11.0 used the last value, so unix:///a?profile=android13plus-v2&profile=android13plus-v0 spoke v0.

  • A ?query after #service is refused (UriErrorKind::QueryAfterService). 0.11.0 made it part of the service name: binder://#svc?driver=/dev/x looked up "svc?driver=/dev/x" on the default driver. Put the query first.

  • A unix:// path or ?driver= may hold percent-escaped bytes that are not UTF-8; 0.11.0 refused unix:///tmp/%FF.sock. A kernel driver path that is not UTF-8 parses but is BadValue at serve / connect, since ProcessState opens the driver by a &str.

  • ?profile=android13plus-vN accepts any decimal spelling of N, so -v02 is version 2; 0.11.0 matched only -v0, -v1 and -v2.

  • A kernel Server::add no longer registers the name; run / spawn do, in this order: check the ServeOptions, start the thread pool, register the names. 0.11.0 registered at add, so an option refused at run / spawn left the name published with no thread to serve it, and its callers blocked. Two consequences:

    • A readiness signal printed between add and run now comes before the name exists, and a client that looks it up once (check_service) can miss it. Signal after spawn instead:

      #![allow(unused)]
      fn main() {
      // 0.11.0
      let server = rsbinder::serve(Uri::kernel())?.add(NAME, svc)?;
      println!("READY");
      server.run()
      
      // 0.12.0
      let _server = rsbinder::serve(Uri::kernel())?.add(NAME, svc)?.spawn()?;
      println!("READY");
      ProcessState::join_thread_pool()
      }
    • A registration error — a name the service manager refuses (invalid, or held by another uid under rsb_hub), a policy denial, an RPC proxy — is returned by run / spawn, not add. Names registered before the failing one stay published and served until the process exits.

Kernel binder, Parcel and process state

  • A kernel option serve / Client::open cannot honor is now BadValue. The KernelEndpoint thread count and driver (binder://?threads=, ?driver=), ServeOptions::threads and ClientOptions::driver are fixed by whoever initializes ProcessState first; a later different value used to log a warning and now returns StatusCode::BadValue (omitting it or repeating the value still works). Likewise ClientOptions::driver / mmap_size must agree with the KernelEndpoint's driver and mapping size. A driver path that is not UTF-8 is BadValue too; 0.11.0 opened its lossy UTF-8 conversion, a different path.
  • ServerGuard and Server are #[must_use]: serve(uri)?.spawn()?; stopped an RPC server at once. Bind the guard to a named variable.
  • A kernel transaction dispatched inside an RPC handler now reports the kernel caller: calling_caller(), get_calling_uid() / pid() / sid() and CallingContext::default() return Caller::Kernel and the kernel sender's values, not the suspended RPC peer's. Re-check handlers that pick an authorization rule by matching on the Caller arm.
  • Outside a transaction get_calling_uid() / get_calling_pid() return this process's own uid and pid instead of 0 (root), as AOSP's IPCThreadState does. Inside a transaction nothing changes.
  • ProcessState::set_call_restriction now restricts the calling thread too (CHANGELOG Fixed). A thread that had already made a binder call kept its previous value; it now takes the new one at once, so a twoway call it makes afterwards logs an error (ErrorIfNotOneway) or panics (FatalIfNotOneway). The main looper of ServeOptions::call_restriction is such a thread: a handler running on it that makes a twoway call now hits the restriction.
  • A kernel Client::get / connect reports the service manager's own failure instead of NameNotFound when it cannot be reached, and hub logs why once.
  • get_calling_sid() is None where SELinux is not available (CHANGELOG Fixed): on Linux without a mounted selinuxfs, Smack and AppArmor hosts included, a binder with BinderFeatures::set_requesting_sid no longer asks the driver for the caller's context. Under Smack or AppArmor 0.11.0 returned the label, read on to the next NUL byte.
  • Parcel is no longer RefUnwindSafe without the rpc feature: a kernel parcel now holds the binder proxies written into it (CHANGELOG Fixed). With rpc it already was not. Wrap a &Parcel in AssertUnwindSafe where catch_unwind needs it.
  • A parcel write over a binder or fd object it recorded is PermissionDenied (CHANGELOG Fixed): rewinding with set_data_position and writing over an object fails where 0.11.0 overwrote it.
  • Parcel::from_ipc_parts has one more # Safety condition: the handle of every BINDER_TYPE_FD object at an offset in objects must be an open file descriptor that stays open until the parcel drops. Reading such an object duplicates that fd, as 0.11.0 already did, and the parcel closes none of them; the signature and behavior are unchanged. Under the 0.11.0 conditions a buffer naming a closed fd number was allowed, and a read then duplicated whatever fd that number named by then.
  • Parcel::__set_for_rpc (rpc + test-util) returns Result<()> and refuses, with InvalidOperation and the mode left unchanged, a parcel that holds bytes or was built over a received buffer (CHANGELOG Fixed). Call it on a new parcel before writing to it.
  • Over kernel binder, a service's on_dump writes to a duplicate of the caller's fd, not to the received fd itself (CHANGELOG Fixed). The duplicate is made before the arguments are read; a process out of file descriptors now answers the DUMP_TRANSACTION with that error instead of running on_dump. Over RPC, on_dump now runs as well (CHANGELOG Fixed).
  • ProxyHandle::dump takes F: Into<OwnedFd>, not F: IntoRawFd. A File, an OwnedFd, a ParcelFileDescriptor, a ChildStdin and the other owning std types compile unchanged. A raw fd number, or a type that implements only IntoRawFd, has to become an OwnedFd first (unsafe { OwnedFd::from_raw_fd(fd) }, with the caller vouching that it owns fd). IntoRawFd is a safe trait that need not hand over ownership, and dump closes the fd it takes, so 0.11.0 would close an fd such an implementation had only lent it.
  • IMemoryHeap has a required heap_fd(&self) -> Option<BorrowedFd<'_>>, and heap_id is now a provided method returning that fd's number (-1 for None). An implementation outside rsbinder no longer compiles until it adds heap_fd: return the heap's fd (Some(self.fd.as_fd())), or None while it has none. An own heap_id may stay, but nothing reads it for the reply: BnMemoryHeap answers HEAP_ID with a duplicate of heap_fd (and BadValue for None). In 0.11.0 it borrowed whatever number the safe heap_id method returned as an fd, so an implementation returning 0 or a closed number let one remote HEAP_ID request send an unrelated fd of the process (stdin, say) to the peer. Callers of heap_id, and the wire, are unchanged.

RPC wire

  • The r34 RPC wire (the default profile) now frames messages as android-12 libbinder does; a 0.12.0 peer and an older one no longer connect. A client writes the int32 session id -1 when it connects, then every message as a bare RpcWireHeader and its body, with no u32 length prefix. An older server reads the -1 as a frame length past MAX_FRAME_LEN, and a 0.12.0 server reads an older client's first length as a session id it does not have; both close the connection. Upgrade both ends together. The new wire was checked against android-12 libbinder on an SDK 31 emulator, with each side as client and as server (example-hello/cpp/run_rpc_r34_interop.sh). Every binder in an r34 parcel, a null one included, is now followed by its stability as android-12 writes it, a Category (0x0c000001 for System, 0x00000001 for null) whatever the host's SDK; a null binder with a declared level or a Category of version 0 is refused with BadType. GET_SESSION_ID on r34 answers android-12's int32, so RpcSession::get_session_id returns 4 bytes there (was 32); a session not accepted by an RpcServer has no id and answers UnknownTransaction. A custom RpcTransport that implements only send_frame / recv_frame can no longer carry any session: RpcSession::new as a client fails with RpcError::Protocol, as the android-13+ profile always did; implement send_raw / recv_raw. RpcSession::new as an acceptor reads the id in its first serve_blocking read and ends the session on any id but -1.
  • RPC on the android-13+ wire: a null binder now carries a stability int32, as libbinder's does (CHANGELOG Fixed). An rsbinder 0.11.0 peer on that wire reads the parcel 4 bytes off after a null binder or a null interface, so update both ends together. Parcels without a null binder are unchanged.
  • An android-13+ RPC session takes its fd mode from the connection header only, as AOSP does. There RpcSession::negotiate_fd_transport sends nothing: it returns the header's mode, and a Unix request the header did not agree is InvalidOperation; 0.11.0 sent GET_FD_MODE and could switch the session to Unix after connecting. Request fd passing before connecting with RpcClientConfig::fd_mode (the fd_mode connect option of the entry API), and on the server with RpcServer::set_supported_fd_modes. A 0.12.0 server answers an android-13+ GET_FD_MODE with UNKNOWN_TRANSACTION, as libbinder does, so a 0.11.0 client calling negotiate_fd_transport on such a session gets UnknownTransaction. On the r34 wire negotiate_fd_transport still negotiates; against a peer without GET_FD_MODE (AOSP r34 libbinder answers UNKNOWN_TRANSACTION) it now returns Ok(FileDescriptorTransportMode::None) where 0.11.0 returned Err(UnknownTransaction), so the first fd write fails (FdsNotAllowed) instead of the negotiation. An android-13 (v0) session has no fd mode in its header and stays None whatever both ends request.
  • An android-13+ RPC server refuses a client that requests an fd mode it does not support, as AOSP does (RpcServer.cpp "Rejecting connection: FileDescriptorTransportMode is not supported"): Unix without RpcServer::set_supported_fd_modes(&[Unix]) (the fd_modes serve option), or Trusty. The server closes the connection after its handshake response, so the client's setup succeeds and its first call fails with DeadObject. 0.11.0 accepted the client and left only the server at None: the client's fd writes went out over SCM_RIGHTS and the server dropped the descriptors. Opt in on the server, or connect without fd_mode. RpcSession::accept_android13plus_fd with server_fd_unix == false (and accept_android13plus) refuses the same way. The r34 wire is unchanged: its GET_FD_MODE still agrees None.
  • RpcSession::accept_android13plus and accept_android13plus_fd refuse a connection header whose session id is neither empty nor 32 bytes, as AOSP RpcServer::establishConnection does, before reading the id; the call returns StatusCode::RpcError. 0.11.0 read the id and ignored it, so a client that sent a 16-byte id got a session. RpcServer is unchanged: it refused such a connection in 0.11.0 too, when the id matched no session.

RPC sessions and connections

  • An RPC session refuses a connection whose transport differs from its founding one (fd passing or local peer) with BadType at attach. A manual attach (add_{outgoing,incoming}_connection_with_config) over such a transport is refused before its header goes out, so the session stays up; a hand-assembled session (e.g. RpcServer::serve_connection) can hit it too.
  • An RPC session ends as a whole when any of its connections fails (AOSP RpcState::handleRpcError). A send or receive failure, a cut frame or an expired deadline on one connection shuts every connection down: calls in flight on the others return DeadObject, every death recipient fires, and every local object the peer held is released. 0.11.0 retired the failing connection and let a fan-out session go on. After a session ends, reconnect, fetch the root and register callbacks again — rsbinder::Reconnecting (CHANGELOG Added) does this for one service. A client's incoming connection whose serve thread cannot be spawned ends the session too, since the server already holds it as a callback connection. So does every other failed incoming attach, except one whose header never went out (a refused pre-check, a failed connect or header write) or whose connection closed, a reset included, before any byte of the server's "cci" arrived (a refused attach): those leave the session up. A failed outgoing attach (add_outgoing_connection_with_config) ends the session too, since a libbinder server holds the connection once it has the header, except one whose header never went out (a refused pre-check, a max_version below the session's, a transport unlike the founding connection's, an id other than the session's get_session_id(), a failed connect or header write). A refused attach ends it as well: libbinder android-16.0.0_r3 and later end their session when they refuse an attach at the setMaxThreads cap, and the close that refusal produces cannot be told from one that left the server's session up. Stay within negotiate() connections and echo get_session_id().
  • An expired reply deadline ends the session (RpcSession::set_timeout, RpcServer::set_reply_timeout, the timeout / reply_timeout options). The call still returns TimedOut; the session no longer survives it or skips the late reply. Set it above the slowest legitimate handler; to abandon one call and keep the session, run it on another thread and stop waiting. A wait for a free connection still fails only that call, with WouldBlock.
  • The session timeout bounds more than the reply wait: SO_SNDTIMEO (a send that makes no progress for the period ends the session; a steady slow reader is not cut) on every connection of a bundled socket transport, and on a transport of your own only if it implements RpcTransport::set_write_timeout (without it a stalled send has no bound), the kernel's keepalive check on TCP and TLS over TCP (RpcTransport::set_liveness has the values and platform differences), and on a client each connect and handshake step (connect(2) itself for tcp_debug and tls only, not for unix or vsock). A send deadline that expires part-way through a frame returns TimedOut (0.11.0: WouldBlock, which means nothing was sent).
  • A tls client's connect deadline is shared by every address the host name resolves to. In 0.11.0 ClientOptions::handshake_timeout gave each address its own full connect(2) deadline, so a host with N addresses that drop the SYN held open for N times it. Now timeout (or the deprecated handshake_timeout of RpcClientConfig and ClientOptions) bounds the tries together: each gets an even share of the time left but at least 2 s (all of it when less is left), an address refused at once leaves its share to the next, and an address slower to accept than its share is given up for the next even when it would have accepted within the deadline. A timeout under 2 s therefore goes to the first address that does not refuse at once. For a host that resolves to several addresses over a link whose connect takes more than the larger of timeout / N and 2 s, raise the deadline or connect to one IP address (with tls_server_name naming the certificate's host).
  • RpcServer::set_handshake_timeout bounds the whole admission phase, not each read. In 0.11.0 it was a socket deadline (SO_RCVTIMEO, SO_SNDTIMEO) that every read or write started over, so a client whose handshake bytes kept coming was admitted however long they took. Now the connection is dropped once the deadline has passed since its accept, its TLS handshake included and the set_authorizer hook's run left out: a client on a link slow enough to need longer than the deadline (10 s by default) for its whole handshake is refused. On the r34 wire a connection that opens a new session ends the phase only once its first transaction has been served, so a large first transaction counts too; a connection that joins an existing session ends it with its session-id preamble. Raise the deadline for such links. A client's handshake deadline (timeout, or the deprecated handshake_timeout of RpcClientConfig, RpcUnixClientConfig and ClientOptions) bounds each TLS or android-13+ handshake step as a whole in the same way: in 0.11.0 handshake_timeout bounded each read, and now a step that outlasts it fails the setup call with TimedOut. RpcSession::from_preconnected_fd bounds its handshake by a fixed 10 s the same way, where 0.11.0 bounded each read by it. No API changes that 10 s: for a peer that needs longer for the whole handshake from_preconnected_fd returns TimedOut, and a service-manager lookup that resolves through such an Accessor finds no service (the reason is logged).
  • An expired TLS handshake is RpcError::Timeout, not Io(WouldBlock): TlsTransport::connect, accept, connect_stream and accept_stream return RpcError::Timeout (StatusCode::TimedOut) when the stream's deadline ends the handshake. 0.11.0 returned RpcError::Io of kind WouldBlock.
  • On Linux and Android a send or read deadline counts the peer taking this end's bytes as progress (SIOCOUTQ; the transport module doc "A slow peer"). A consumer that reads a large reply slower than set_idle_timeout's or the session timeout's period, but keeps reading, is no longer cut mid-send, nor while the tail of a reply whose send returned is still on its way, and a reply wait goes on while the peer is still taking the request. Apple keeps the 0.11.0 rule: a send's progress is a send that accepted bytes, and a read deadline counts only the peer's bytes. So does a socket that refuses SIOCOUTQ (Android SELinux grants ioctl on another domain's socket only where policy says so, and a kernel whose vsock does not report its queue refuses it on a vsock socket); it is asked once, not on every wait.
  • TCP connections, TLS over TCP included, have keepalive on by default, at the system's intervals without a session timeout (hours), so a session whose peer host vanished ends and fires its death recipients. No wire byte changes.
  • RpcServer::set_idle_timeout judges the session, not the connection: a session ends once no byte has crossed any of its connections and no call has been open for the period, so a fan-out client with one quiet connection is not evicted, and the eviction can come up to one period later than that; the set_idle_timeout rustdoc states the rule.
  • A kernel ETIMEDOUT on an RPC connection is a dead connection, not a deadline of this end's (keepalive or TCP_USER_TIMEOUT gave up): the session ends, and a serve loop reports NotLocal / Lost instead of an idle eviction.
  • RPC link_to_death (and death_signal) is refused with InvalidOperation on a session that would not notice its connection dropping (a client with no incoming connections and no serve loop). Open incoming connections (incoming_connections(n)), or call the new RpcSession::spawn_serve before linking; the server side is unaffected.
  • A oneway call made inside an RPC handler needs a connection this end opened (CHANGELOG Fixed); without one it is WouldBlock, so a server's oneway callback needs the client's incoming connections. Twoway is unchanged.
  • RpcServer::setup_unix_server removes only a stale socket: a regular file is refused with AlreadyExists, a listening socket with EADDRINUSE. Drop removes the socket file only while it is still the one bound. On Apple platforms it removes no socket at all and refuses every existing one with EADDRINUSE, as AOSP setupUnixDomainServer does, so delete a crashed server's socket before restarting: XNU refuses a connect to a live listener whose backlog is full with the same ECONNREFUSED a stale socket gives, so probing could remove a running server's path. 0.11.0 removed whatever was at the path on every platform.
  • An RPC Parcel is sent once (CHANGELOG Fixed): build a new request per call. Sending the same parcel again, or a reply or incoming parcel as a request, returns InvalidOperation; a parcel built on another session's proxy returns BadType (AOSP RpcState::validateParcel), as do a local binder written into a sent parcel and a ParcelableHolder read on one session written into a parcel of another. In 0.11.0 a scalar-only request could be resent. RpcProxy::transact refuses a parcel built for no session (Parcel::new()) with BadType; build it with build_request. A handler that replaces its reply with such a parcel, or with one built on another session, sends the caller BadType; with a received or already-sent parcel, InvalidOperation. 0.11.0 sent their bytes.
  • An unresolved ParcelableHolder cannot be relayed over an r34 or android-13 v0/v1 RPC session; relay requires wire v2 (android-16). Writing a holder that was read but not decoded with get_parcelable into a parcel of such a session is BadType, even when the payload holds only data: those wires do not record where a binder sits, so the copy could neither take the references its binders need nor prove there are none. Decode the payload first and write the typed value. On v2 the relay carries binders and fds. In 0.11.0 the bytes were copied as they were, and relaying a holder that held one of the peer's own references freed the node early.
  • A session parcel takes a holder's bytes only from its own session (AOSP Parcel::appendFrom): a holder read from a kernel parcel, decoded with from_bytes or read on another session, written undecoded into an RPC session parcel, is BadType. It was copied as is.
  • An android-13+ RPC client setup refuses a session id, and an attach refuses one other than its session's own, with BadValue. The setup refuses before any handshake a non-empty id passed to setup_unix_client_android13plus_with_id, setup_unix_client_android13plus_with_config (RpcUnixClientConfig::session_id), connect_android13plus_fd_with_id or ClientOptions::session_id, all now deprecated, and likewise the new setup_client_android13plus_with_config with RpcClientConfig::session_id. In 0.11.0 they built a second client RpcSession on the server session another one founded; the two kept separate oneway numbering, binder addresses and lifetimes, so the server dropped one side's oneway calls as stale, could dispatch a call for one side's binder to the other side's binder at the same address, and ended the shared session when either side closed. AOSP has no public entry for this either (RpcSession::setupClient is private). To add a connection to a session, call add_outgoing_connection_with_config (or add_incoming_connection_with_config) on that session with RpcClientConfig::session_id(&session.get_session_id()?); attaching from another process is not supported, as in AOSP. The wire is unchanged: a server still admits an attach that echoes its id. A manual attach (add_outgoing_connection_with_config, add_incoming_connection_with_config and the deprecated add_{outgoing,incoming}_connection_android13plus* forms) likewise refuses, with BadValue before connecting, every id other than its own session's server-minted one (get_session_id(), which the session now keeps; an attach on a session that never called it makes that round trip itself). 0.11.0 let one RpcSession attach a connection to another server session, which split that server session across two client states the same way and pooled connections to two server sessions in one client session.
  • Several RPC client setup calls are deprecated in favor of RpcClientConfig (CHANGELOG Deprecated); they still work, except for the session ids now refused (see above), but -D warnings flags them.

rsbinder-aidl

  • rsbinder-aidl now rejects .aidl that AOSP's aidl also rejects (CHANGELOG Added). An rsbinder-only contract relying on the missing validation now fails, with a diagnostic naming the rule.
  • error::SemanticError is now #[non_exhaustive] and gained FixedSizeNonFixedField, VintfStabilityLeak and DirectionNotSpecified; add a _ => … arm. DirectionPrimitive is replaced by InvalidDirection, which covers every refused direction and adds the argument name and the permitted directions; its type_kind is the AIDL type (int, String), not "a primitive type".
  • render::ConstMember and render::EnumMember gained a trailing deprecation element; pass String::new() to keep the previous output. The #[non_exhaustive] render structs' new deprecated field needs no change.
  • rsbinder-aidl parser API: ParcelableDecl::type_params and UnionDecl::type_params are Vec<TypeParam> (name, span, annotations) instead of Vec<String>; read TypeParam::name. New Generic::type_args() returns a Foo<A, B> reference's arguments as written.
  • #[deprecated] codegen can break a downstream -D warnings build: an existing /** @deprecated … */ javadoc now generates #[deprecated]. Drop the tag, or allow the lint at the call site.
  • A declaration nested in a @VintfStability type is now VINTF-stable (CHANGELOG Fixed): a ParcelableHolder records VINTF stability for it, and ParcelableHolder::set_parcelable returns BadValue for a payload without VINTF stability. Nothing in the build warns.
  • rsbinder-aidl: a @FixedSize union's implicit Tag is Tag(pub i8) (CHANGELOG Fixed): code that builds or reads it as i32 no longer compiles, and a Tag[] is byte[] on the wire. A @FixedSize union with more than 128 fields is rejected, because its byte tag cannot number the 129th; AOSP's aidl rejects it as well.
  • rsbinder-aidl: a u32/u64 suffix on a hex literal no longer sets its width (CHANGELOG Fixed), as in AOSP ParseIntegral: 0xFFFFFFFFu64 is the int -1, so a long constant set to it is -1, not 4294967295, and 0x1FFFFFFFFu32 is a long instead of a parse error. Nothing in the build warns; a constant sent on the wire changes value.
  • rsbinder-aidl: @nullable(heap=true) is no longer ignored. A parcelable or union field marked with it is Option<Box<T>> wherever it is, as AOSP's Rust backend renders it (aidl_to_rust.cpp:305-306); only one on a reference cycle was boxed before, so code building or matching such a field off a cycle as Option<T> stops compiling (wrap the value in Box::new). Such a field no longer counts as an inline edge either, so a bare @nullable field it cuts off a cycle is Option<T>, not Option<Box<T>>: in parcelable A { @nullable(heap=true) B b; } parcelable B { @nullable A a; }, B.a is now Option<A>. heap=true on anything but a parcelable or union type (a String, an array, an interface) and any @nullable parameter but heap are refused, with AOSP's messages. The wire is unchanged. Method arguments and returns keep their types.
  • rsbinder-aidl: a union whose first field is an enum without an initializer defaults to the enum's Default (backing value 0), not to its first enumerator (CHANGELOG Fixed). For enum E { A = 5, B = 6 } and union U { E e; … }, U::default() is U::E(E::default()), which is E(0), not U::E(E::A); a default-constructed union sent as is carries 0 on the wire. An enum whose first enumerator is 0 is unaffected. Nothing in the build warns.
  • rsbinder-aidl / rsbinder-macros: fixed-size array arguments and returns have AOSP's element types (CHANGELOG Fixed). A @nullable T[N] in argument or return value whose element is not a primitive or enum now wraps each element: Option<&[Option<T>; N]> and Option<[Option<T>; N]>, not Option<&[T; N]> and Option<[T; N]>. An inout T[N] of a binder, interface or ParcelFileDescriptor is &mut [T; N], not &mut [Option<T>; N]. Service implementations and callers of such methods stop compiling until updated, and #[rsbinder::interface] refuses the old spellings and names the new one. The bytes on the wire do not change.
  • rsbinder-aidl: a union variant is its field's name with only the first letter uppercased (CHANGELOG Fixed), as AOSP names it: nullable_iface is Nullable_iface (was NullableIface) and URL is URL (was Url). A single lowercase word (ns → Ns) or a lowerCamel name (myField → MyField) keeps the variant it had. Code naming any other variant stops compiling. The wire carries the tag number only, so it does not change.
  • rsbinder-aidl: one statement may hold at most 12 < that are followed by a name and not closed by a > (CHANGELOG Fixed); 0.11.0 allowed 256. A comparison counts, so a constant such as A < B || C < D || … with 13 or more such comparisons is now rejected; split it across several constants (each ; resets the count).
  • rsbinder-aidl checks argument directions as AOSP aidl does (GetArgumentAspect, AidlArgument::CheckValid); 0.11.0 generated code for all of the following. Refused: out or inout on an IBinder, an interface or an enum (in only, @nullable or not) and on a @JavaOnlyImmutable parcelable or union; out ParcelFileDescriptor (in or inout only); and an omitted direction on a type that takes more than in — any array (fixed-size too, whatever the element), List, a parcelable or union (generic too) and ParcelFileDescriptor. Write in where the direction was omitted; the generated Rust does not change. For an out binder, interface or enum, return it, or use an array (out IBinder[] is &mut Vec<Option<SIBinder>>, and an element left None goes back as a null binder); for an out fd, use inout. An out or inout primitive or String stays refused, now as SemanticError::InvalidDirection.
  • #[rsbinder::interface] and .aidl refuse AOSP's reserved method signatures — asBinder(), getInterfaceHash() and getInterfaceVersion() with no argument, and getTransactionName(int) — as AOSP aidl does (AidlInterface::CheckValid). Rename such a method; its transaction code, set by declaration order, does not change.
  • rsbinder-aidl: an enum reference in a constant expression takes the type of the enumerator's value, not the enum's @Backing width, as AOSP AidlConstantReference::evaluate copies the referenced value's type (@Backing only bounds the value, AidlEnumerator::CheckValid). 0.11.0 widened to @Backing, so in a long-backed enum BIT = 1 then BIT << 40, and an implicit member after 2147483647, folded; both are now int overflows. Write the literal at the width you need (BIT = 1L). A unary operator applies at that type too: with A = true, -E.A is true and ~E.A is an error; with A = 128u8, -E.A overflows byte.
  • rsbinder-aidl binds a dotted name as AOSP ResolveName does, for constant references and type names alike: the first segment is looked up in the enclosing types (innermost first), then the imports, then the document's own top-level types, and the rest of the name must exist inside what it found; a first segment found nowhere makes the name fully qualified. A type of the same package declared in another file without an import (an rsbinder extension; AOSP rejects it) is tried only after that, so it never outranks an import or a fully-qualified name. In a package-less file an import now outranks a package-less type of the same simple name. So Q.X looks for X in the type Q names and nowhere else. 0.11.0 also tried shorter suffixes of a wrong qualifier (wrong.p.I.X folded p.I.X), kept searching outer scopes after an inner type took the first segment (X.Y.Z.C beside a nested X folded the top-level one), accepted a qualifier that repeats a type's name (X.X.C, E.E.A, a field of type X.X), let an unqualified X find a constant inside a nested type also named X, and could fold P.X to a nested type's constant instead of P's. These names are now errors and P.X folds to P's constant. Name a shadowed outer type fully qualified (p.X.Y).
  • rsbinder-aidl looks an unqualified constant up in the current type only, as AOSP AidlConstantReference::Resolve does. 0.11.0 also searched enclosing interfaces, so interface I { const int K = 5; enum E { A = K } } built with rsbinder but fails in AOSP aidl ("Can't find K in E"). Qualify the reference: A = I.K. This includes a field default of enum type: E e = B; is an error (AOSP "Can't find B in P"); write E.B.
  • rsbinder-aidl and #[rsbinder::interface] reserve the __Rsb name prefix (CHANGELOG Fixed). An .aidl interface, parcelable, enum or union whose name starts with it, an interface trait so named, and a signature path whose first segment starts with it are now rejected; AOSP's aidl accepts such names. Rename the type, or reach it in a signature through a path such as crate::….
  • rsbinder-aidl: an AIDL keyword is a whole word and never a name, as AOSP's lexer (aidl_language_l.ll) tokenizes it. A field, argument or enumerator named in, out, inout, package, import, const, interface, parcelable, enum, union or oneway is now a syntax error (int in; used to pass when ; or , followed the name); rename it. Text such as interfaceIFoo {, packagep; or oneway oneway interface is rejected too. AOSP's aidl rejects all of these, so an .aidl shared with an Android build is unaffected.

rsbinder-macros

  • #[rsbinder::interface] and the derives now refuse shapes .aidl cannot express, so moving an interface to .aidl stays a no-op. The diagnostic names the .aidl form to use instead. Refused: directions and nullability AIDL lacks (out String, Option<i32>, a bare out binder or fd); in spellings .aidl never renders (&String, &Vec<T>, &i32, a by-value String or binder); array-element Options spelled the other way (&mut Vec<Option<Cfg>>, Option<&[String]>) and i8 in an array element; scalars AIDL has no type for (u32, u64, usize, u128, char); and other shapes such as BinderResult<T, E>, () or &mut [T] arguments, duplicate names, receiver attributes, an empty descriptor, and on #[derive(Parcelable)] a bare binder or fd field.
  • #[rsbinder::interface] refuses an out or #[inout] binder object and an out ParcelFileDescriptor, Option or not, as AOSP aidl does (GetArgumentAspect: IBinder and an interface go in only, a fd in or inout). &mut Option<rsbinder::SIBinder>, &mut Option<Strong<dyn IFoo>> and &mut Option<ParcelFileDescriptor> compiled in 0.11.0 and had no Android .aidl counterpart. Return the object instead, pass the fd #[inout], or use an array, which takes every direction (out: &mut Vec<Option<_>>, #[inout]: &mut Vec<_>).
  • #[rsbinder::interface] refuses two interfaces in one module whose names share a stem — IFoo and Foo both name BnFoo/BpFoo (AOSP ClassName drops a leading I before an uppercase letter). This failed with E0659 only once BnFoo or BpFoo was used; it now fails at the second declaration with E0428 on __rsbinder_one_interface_per_stem_Foo. Move one of the interfaces to another module or rename it.

rsbinder-tools

  • rsb_hub checks every directory above its configuration, not only the configuration itself (CHANGELOG Changed). A group- or world-writable (sticky or not) or foreign-owned directory anywhere on the path refuses the load: one kept under /tmp no longer loads, nor does ~/rsbinder/hub.d when ~/rsbinder was made 0775 under umask 002. For an unprivileged rsb_hub, move it under $XDG_RUNTIME_DIR or $HOME; a root rsb_hub needs a root-owned path such as /etc/rsbinder/hub.d. Either way chmod g-w,o-w the directories on the way. rsbinder_tools::config::check_path is removed: the check now runs on held descriptors, and a path-based probe would report a different inode than the one read.
  • rsb_hub refuses configurations 0.11.0 loaded: it exits 1 at start, and a SIGHUP reload keeps the policy in force. A [[service]] name must be interface/instance with both halves non-empty, within addService's name rule (at most 127 bytes of [A-Za-z0-9._/-]); a bare interface name, which 0.11.0 read as <name>/default, is refused. A systemd unit must not be empty, an exec program must be an absolute path, and connection.port must be 1–65535 (ConfigError::Toml otherwise). An exec program must exist and pass the check the configuration itself gets (CHANGELOG Security) — the program and every directory on the way to it from / owned by root or rsb_hub's uid and not group- or world-writable — so a program under /tmp or under a group-writable directory is refused. --config must name a regular file or a directory; in a directory, a *.toml entry that cannot be opened (a dangling symlink) fails the load, where 0.11.0 skipped it, and a file whose name starts with . (an editor lock or backup file) is no longer read. rsbinder_tools::config::ConfigError gained EmptyUnit, RelativeExec, UntrustedExec, ExecIo and BadServiceName; an exhaustive match needs the new arms. config::InstanceName::parse returns Option<InstanceName>: None without a / or with an empty half.
  • rsb_hub answers differently in three cases. getDeclaredInstances returns Security when the interface has declared instances and the caller may find none of them, as AOSP ServiceManager.cpp does; 0.11.0 returned an empty list, so rsb_service instances exits 2, not 1. addService succeeds once the service is registered even if an onRegistration callback fails; 0.11.0 returned that callback's error. rsb_hub exits 1 when its signal, death-notification or client-callback thread cannot start, or sigwait fails, where 0.11.0 kept serving without that thread.
  • rsbinder_tools::nss::gids_for_uid returns Option<BTreeSet<u32>>: None is a failed lookup, not an unknown uid (that is Some of an empty set). nss::GroupCache and config::Enforcer hold a boxed resolver and are no longer UnwindSafe / RefUnwindSafe; wrap them in AssertUnwindSafe where catch_unwind needs it.
  • rsb_service exits 2, not 1, when the device has no service manager: 0.11.0 check and dump reported every name as not registered there. rsb_device refuses a device name over 255 bytes or naming a binderfs entry (binder-control, features, binder_logs) before it mounts anything; 0.11.0 applied --group / --mode to that entry.

Enable binder for Linux

Most Linux distributions do not have Binder IPC enabled by default, so additional steps are required to use it.

Note: The binder driver was mainlined in Linux kernel 4.17, but binderfs — which rsbinder's rsb_device tool uses to create binder devices — landed in kernel 5.0. In practice you need kernel 5.0 or later.

If you are able to build the Linux kernel yourself, you can enable Binder IPC by adding the following kernel configuration options:

CONFIG_ANDROID=y
CONFIG_ANDROID_BINDER_IPC=y
CONFIG_ANDROID_BINDERFS=y

Distribution-Specific Guides

Select your Linux distribution for detailed setup instructions:

Enable Binder IPC on Arch Linux

Arch Linux provides an easy way to enable Binder IPC support through the linux-zen kernel, which already includes all necessary Binder components.

Install linux-zen Kernel

The linux-zen kernel is the recommended and simplest method to get Binder IPC support on Arch Linux:

# Update system packages
$ sudo pacman -Syu

# Install linux-zen kernel and headers
$ sudo pacman -S linux-zen linux-zen-headers

# Update bootloader configuration
$ sudo grub-mkconfig -o /boot/grub/grub.cfg

# Reboot to use the new kernel
$ sudo reboot

After reboot, select the zen kernel from the GRUB menu or set it as default.

Verification

After installing and booting into the zen kernel, verify Binder support is available:

# Check current kernel
$ uname -r
# Should show something like "6.x.x-zen1-1-zen"

Install rsbinder-tools

Install the rsbinder development tools:

# Install Rust (if not already installed)
$ sudo pacman -S rustup
$ rustup default stable

# Install rsbinder-tools from crates.io
$ cargo install rsbinder-tools

Create and Test Binder Device

Create a binder device and test the setup:

# Create binder device (binderfs devices are not persistent —
# re-run this after each reboot)
# Create the `binder` group and put yourself in it (log out and back in,
# or use `newgrp binder`, for the membership to take effect)
$ sudo groupadd -f binder
$ sudo usermod -aG binder "$USER"

# Create the device, owned by that group. The node's mode is the only gate
# on who may speak binder at all, so it defaults to 0600 (root only).
$ sudo rsb_device binder --group binder --mode 0660

# Verify device creation
$ ls -la /dev/binderfs/binder

# Test with a simple example
$ git clone https://github.com/hiking90/rsbinder.git
$ cd rsbinder

# Start service manager in one terminal. --insecure-allow-all runs with no
# access control; see the Service Manager chapter for writing a policy.
$ rsb_hub --insecure-allow-all

# In another terminal, run the example
$ cargo run --bin hello_service &
$ cargo run --bin hello_client

Persistent Configuration

The linux-zen kernel builds binder directly into the kernel (CONFIG_ANDROID_BINDER_IPC=y), so there is no module to load on boot — no modules-load.d or modprobe configuration is needed. The only non-persistent piece is the binderfs device itself: re-run sudo rsb_device binder --group binder --mode 0660 after each reboot (see above). The group itself persists; only the device node does not.

Note: binder_linux is the out-of-tree Anbox DKMS module, not part of the zen kernel. modules-load.d / modprobe binder_linux instructions found elsewhere apply only to that third-party module setup on kernels without built-in binder support.

Troubleshooting

If you encounter issues:

# Check kernel messages for binder-related errors
$ dmesg | grep -i binder

# Verify zen kernel is running
$ uname -r | grep zen

# Verify binder is built into the kernel config
$ zgrep -E "(ANDROID|BINDER)" /proc/config.gz

References

Enable Binder IPC on Ubuntu Linux

Note: This guide is community-contributed and may require adjustments for your specific system configuration. Please test in a safe environment first.

Ubuntu Linux does not enable Binder IPC by default. Here are methods to enable it:

Method 1: Check Existing Kernel Support

Some Ubuntu kernels already include binder support. Check your current kernel first:

# Check if binder is available in your kernel
$ grep -E "(ANDROID|BINDER)" /boot/config-$(uname -r)

If you see CONFIG_ANDROID_BINDER_IPC=y or =m, binder support is already available. Skip to the Verification section.

Method 2: Build Custom Kernel

If your kernel does not include binder support, you can build a custom kernel:

# Install build dependencies
$ sudo apt install build-essential libncurses-dev bison flex libssl-dev libelf-dev

# Download kernel source (replace version as needed)
$ wget https://cdn.kernel.org/pub/linux/kernel/v6.x/linux-6.12.tar.xz
$ tar -xf linux-6.12.tar.xz
$ cd linux-6.12

# Use current kernel config as base
$ cp /boot/config-$(uname -r) .config

# Configure kernel with binder support
$ make menuconfig

# Navigate to: General setup -> Enable Android support
# Enable:
# CONFIG_ANDROID=y
# CONFIG_ANDROID_BINDER_IPC=y
# CONFIG_ANDROID_BINDERFS=y

# Build and install
$ make -j$(nproc)
$ sudo make modules_install
$ sudo make install
$ sudo update-grub
$ sudo reboot

Verification

After enabling binder support, verify it's working:

# Check if binderfs is supported
$ grep binderfs /proc/filesystems

# Test creating a binder device (requires rsbinder-tools)
$ cargo install rsbinder-tools
# Create the `binder` group and put yourself in it (log out and back in,
# or use `newgrp binder`, for the membership to take effect)
$ sudo groupadd -f binder
$ sudo usermod -aG binder "$USER"

# Create the device, owned by that group. The node's mode is the only gate
# on who may speak binder at all, so it defaults to 0600 (root only).
$ sudo rsb_device binder --group binder --mode 0660

Troubleshooting

Common Issues:

  1. Module not found: Ensure your kernel was built with binder support enabled
  2. Permission denied: Make sure you're using sudo for device creation
  3. Kernel too old: The binder driver was mainlined in kernel 4.17, but binderfs (used by rsb_device) requires kernel 5.0+

Getting Help:

  • Check dmesg for kernel messages: dmesg | grep -i binder
  • Verify binder is enabled in the kernel config: grep -E "(ANDROID|BINDER)" /boot/config-$(uname -r) (note: binder_linux is the out-of-tree Anbox DKMS module — modprobe binder_linux does not apply to a kernel built with the options above)
  • Check system logs: journalctl -f

References

Enable Binder IPC on RedHat Linux (RHEL/CentOS/Fedora)

Note: This guide is community-contributed and may require adjustments for your specific system configuration. Please test in a safe environment first.

RedHat-based distributions (RHEL, CentOS, Fedora) do not include Binder IPC support by default, so enabling it means building a kernel.

Fedora

Fedora provides kernel source packages that can be modified to include binder support:

# Install development tools
$ sudo dnf groupinstall "Development Tools"
$ sudo dnf install fedora-packager fedpkg
$ sudo dnf install kernel-devel kernel-headers

# Install kernel build dependencies
$ sudo dnf builddep kernel

# Get kernel source
$ fedpkg clone -a kernel
$ cd kernel
$ fedpkg switch-branch f$(rpm -E %fedora)
$ fedpkg prep

# Modify kernel config to enable binder
$ cd ~/rpmbuild/BUILD/kernel-*/linux-*
$ make menuconfig

# Enable the following options:
# General setup -> Android support (CONFIG_ANDROID=y)
# CONFIG_ANDROID_BINDER_IPC=y
# CONFIG_ANDROID_BINDERFS=y

# Build the kernel
$ make -j$(nproc)
$ sudo make modules_install
$ sudo make install

# Update bootloader
$ sudo grub2-mkconfig -o /boot/grub2/grub.cfg
$ sudo reboot

RHEL/CentOS

Build a Custom Kernel

For enterprise distributions, building a custom kernel is often the most reliable approach:

# Install EPEL repository (CentOS/RHEL 8+)
$ sudo dnf install epel-release

# Install development tools
$ sudo dnf groupinstall "Development Tools"
$ sudo dnf install rpm-build rpm-devel libtool

# Install kernel build dependencies
$ sudo dnf install kernel-devel kernel-headers
$ sudo dnf install elfutils-libelf-devel openssl-devel

# Download kernel source matching your running kernel.
# Pick the v<major>.x directory that matches your kernel: today this
# is typically v6.x on Fedora/Stream; older RHEL 8/9 hosts may still
# be on v5.x or v4.x.
$ KERNEL_VERSION=$(uname -r | sed 's/\.el.*$//')
$ KERNEL_MAJOR=$(echo $KERNEL_VERSION | cut -d. -f1)
$ wget https://cdn.kernel.org/pub/linux/kernel/v${KERNEL_MAJOR}.x/linux-${KERNEL_VERSION}.tar.xz
$ tar -xf linux-${KERNEL_VERSION}.tar.xz
$ cd linux-${KERNEL_VERSION}

# Use current kernel config as base
$ zcat /proc/config.gz > .config
# or
$ cp /boot/config-$(uname -r) .config

# Modify config to enable binder
$ make menuconfig
# Enable CONFIG_ANDROID=y, CONFIG_ANDROID_BINDER_IPC=y, CONFIG_ANDROID_BINDERFS=y

# Build and install
$ make -j$(nproc)
$ sudo make modules_install
$ sudo make install

# Update GRUB
$ sudo grub2-mkconfig -o /boot/grub2/grub.cfg
$ sudo reboot

CentOS Stream

CentOS Stream may have more recent kernels that could include binder support:

# Check current kernel version
$ uname -r

# Update to latest kernel
$ sudo dnf update kernel

# Check if binder is already available
$ grep -E "(ANDROID|BINDER)" /boot/config-$(uname -r)

After Kernel Build

With the kernel built as described above, binder is compiled directly into the kernel (CONFIG_ANDROID_BINDER_IPC=y is a built-in option, not a module), so nothing needs to be loaded with modprobe or configured in modules-load.d:

# Verify binder is built into the running kernel
$ grep -E "(ANDROID|BINDER)" /boot/config-$(uname -r)

Note: modprobe binder_linux / modules-load.d instructions found elsewhere refer to binder_linux, the out-of-tree Anbox DKMS module for kernels without built-in binder support. They do not apply to a kernel built with the options above.

SELinux Considerations

RedHat systems run SELinux, which can deny access to the binder device even once the node exists and its mode is right. A denial looks like a failed open rather than an rsbinder error, so check the audit log before suspecting anything else:

$ sestatus
$ sudo ausearch -m avc -ts recent | grep -i binder

The fix is a policy module allowing your domain to use the device. Putting the whole system in permissive mode (setenforce 0) will also make the denial go away, but it disables SELinux for everything else on the machine — reach for it only to confirm a diagnosis, never as the resting state.

Verification

Test that binder is working:

# Check if binderfs is available
$ grep binderfs /proc/filesystems

# Install rsbinder-tools and create binder device
$ cargo install rsbinder-tools
# Create the `binder` group and put yourself in it (log out and back in,
# or use `newgrp binder`, for the membership to take effect)
$ sudo groupadd -f binder
$ sudo usermod -aG binder "$USER"

# Create the device, owned by that group. The node's mode is the only gate
# on who may speak binder at all, so it defaults to 0600 (root only).
$ sudo rsb_device binder --group binder --mode 0660

# Verify device creation
$ ls -la /dev/binderfs/binder

Troubleshooting

Common Issues:

  1. Module compilation fails: Ensure all kernel-devel packages match your running kernel
  2. SELinux denials: Check audit.log for SELinux denials and create appropriate policies
  3. Kernel version mismatch: Ensure kernel source matches your running kernel version

Debugging:

# Check kernel messages
$ dmesg | grep -i binder

# Check system journal
$ journalctl -f | grep -i binder

# Verify kernel config
$ grep -E "(ANDROID|BINDER)" /boot/config-$(uname -r)

References

Android Development

Android already has a complete Binder IPC environment, so rsbinder and rsbinder-aidl use the existing device nodes and service manager directly. Nothing here needs rsb_device or rsb_hub, which exist for Linux.

For building in the Android environment, you need to install the Android NDK and set up a Rust build environment that utilizes the NDK.

See: Android Build Environment Setup

Android Version Compatibility

rsbinder supports multiple Android versions with explicit feature flags for compatibility management. The Binder IPC interface has evolved across Android versions, and rsbinder handles these differences transparently.

Supported Android Versions

  • Android 10 (API 29): android_10 feature
  • Android 11 (API 30): android_11 feature
  • Android 12 (API 31) / 12L (API 32): android_12 feature
  • Android 13 (API 33): android_13 feature
  • Android 14 (API 34): android_14 feature
  • Android 15 (API 35): android_14 and android_15 — see below
  • Android 16 (API 36): android_16 feature
  • Android 17 (API 37): served by the android_16 feature

Note: Android 12L (API 32) uses the same Binder protocol as Android 12, so both are covered by the android_12 feature flag, and Android 17 (API 37) uses the same protocol as Android 16, so android_16 or android_16_plus covers it. No separate android_12l or android_17 feature is needed.

Android 15 has two service-manager protocols: android-15.0.0_r6 inserted getService2 at index 1 of IServiceManager and shifted every transaction code after it by one (addService 2 → 3, registerClientCallback 11 → 12, tryUnregisterService 12 → 13). The SDK version stayed 35, so the build alone does not say which numbering a device speaks. The initial release (r1–r5) is served by android_14; r6 and later, QPR builds included, by android_15. Enable both — android_14_plus and everything wider does — and rsbinder probes the running service manager once at startup to pick the right one. With only one of the two enabled, a device that speaks the other is refused with an error naming the missing feature, rather than addressed with the wrong transaction codes.

Android 10 caveat: Android 10 uses the legacy C IServiceManager (the AIDL-based interface only landed in Android 11). The android_10 dispatch supports try_get_service, check_service, add_service, and list_services. APIs introduced later — is_declared, register_for_notifications, unregister_for_notifications, and get_service_debug_info — return false or StatusCode::UnknownTransaction so callers can detect the gap rather than silently misbehave.

Feature Flag Configuration

In your Cargo.toml, specify the Android versions you want to support:

[dependencies]
rsbinder = { version = "0.12", features = ["android_14_plus"] }

Available feature combinations:

  • android_10_plus: Supports Android 10 through 17
  • android_11_plus: Supports Android 11 through 17
  • android_12_plus: Supports Android 12 through 17
  • android_13_plus: Supports Android 13 through 17
  • android_14_plus: Supports Android 14 through 17
  • android_15_plus: Supports Android 15 (r6 and later only) through 17
  • android_16_plus: Supports Android 16 and 17

Protocol Compatibility

rsbinder maintains binary compatibility with Android's Binder protocol:

  • Transaction Format: Uses identical binder_transaction_data structures
  • Object Types: Supports all Android Binder object types (BINDER, HANDLE, FD)
  • Command Protocols: Implements the same ioctl commands (BC_/BR_ protocol)
  • Memory Management: Compatible parcel serialization and shared memory handling
  • AIDL Compatibility: Generates code compatible with Android's AIDL interfaces

Because the wire format is identical, the language a service is implemented in is invisible to its callers: a client generated by rsbinder-aidl can call an existing Android service written in C++ or Java exactly as it would call an rsbinder one — no JNI or C shim required. Vendor the service's .aidl from AOSP, build it with rsbinder-aidl, and look the service up with hub::try_get_interface::<dyn IFoo>("service.name"). rsbinder relies on this itself: its hub module drives Android's own C++ servicemanager exactly this way. The same generated stubs also drive the RPC transport, which is wire-compatible with real libbinder RPC peers in both directions — though AOSP exposes only a few services that way (VM / accessor-mediated) rather than over kernel binder.

Version Detection (Optional)

rsbinder uses rsproperties internally to read Android system properties. You can use the same crate for version detection:

#![allow(unused)]
fn main() {
// Read Android SDK version (returns default value if not available)
let sdk_version: u32 = rsproperties::get_or("ro.build.version.sdk", 0);
println!("Android SDK version: {}", sdk_version);

// Read release version string
let version: String = rsproperties::get_or("ro.build.version.release", String::new());
println!("Running on Android {}", version);
}

Add rsproperties to your dependencies:

[dependencies]
rsproperties = "0.6"

Using Android's Existing Binder Devices

On Android, the binder device files are already created and managed by the system. Use ProcessState::init() to connect to the appropriate device:

#![allow(unused)]
fn main() {
// Connect to the default system binder (/dev/binder)
ProcessState::init("/dev/binder", rsbinder::DEFAULT_MAX_BINDER_THREADS)?;
}

The second argument is the binder thread-pool ceiling, passed to the kernel as written. DEFAULT_MAX_BINDER_THREADS (15) is what init_default() uses; pass 0 only when the process must never have the kernel spawn a binder thread for it — a single-threaded service manager, or a client that only makes synchronous outbound calls.

Android provides several binder devices for different purposes:

DeviceService ManagerDescription
/dev/binderservicemanagerFramework services (default)
/dev/hwbinderhwservicemanagerHAL services (HIDL)
/dev/vndbindervndservicemanagerVendor services

Warning: hwbinder uses a different protocol (libhwbinder) than standard binder (libbinder). rsbinder has not been tested with hwbinder, so compatibility is not guaranteed.

You do not need to run rsb_hub on Android — the system already provides service managers for each binder device.

Android-Specific Considerations

  • SELinux applies as it does to any other process: a denial surfaces as a failed open of the device or a refused transaction, not as an rsbinder error.
  • Shared memory is allocated with memfd_create; a peer's legacy /dev/ashmem fd is accepted on the way in. See Shared Memory.
  • @VintfStability is honored, including the stability check a ParcelableHolder carries on the wire.

JNI Integration

rsbinder is designed for pure Rust-based programs. Integrating Rust Binder services with Java through JNI is not recommended and was not considered in the design. Since JNI only provides a C interface, multiple data conversions occur (Java → C → Rust), which is inefficient. Instead, develop independent Binder services in Rust and communicate with them from Java clients through the standard Binder IPC mechanism.

Android Build Environment Setup

What you need to build and test rsbinder on Android: the SDK for adb and the emulator, the NDK for the native toolchain, and cargo-ndk to drive them from cargo.

Prerequisites

Android SDK Installation

You need to install the Android SDK which includes essential tools like adb (Android Debug Bridge) and other platform tools required for Android development.

Download and install Android Studio, which includes the Android SDK:

Method 2: Command Line Tools Only

If you prefer a minimal installation:

  1. Download Command Line Tools from Android Developer Downloads
  2. Extract and set up the SDK manager

Required SDK Components

Install the following SDK components using the SDK Manager:

# Install platform-tools (includes adb)
$ sdkmanager "platform-tools"

# Install emulator (for testing)
$ sdkmanager "emulator"

# Install system images for testing (choose your target API levels)
$ sdkmanager "system-images;android-30;google_apis;x86_64"
$ sdkmanager "system-images;android-34;google_apis;x86_64"

Android NDK Installation

The Android NDK (Native Development Kit) is required for building native Rust code for Android.

Method 1: Through Android Studio

  • Open Android Studio
  • Go to Tools → SDK Manager → SDK Tools
  • Check "NDK (Side by side)" and install

Method 2: Command Line Installation

# Install specific NDK version
$ sdkmanager "ndk;26.1.10909125"
# Check for the latest available version:
$ sdkmanager --list | grep ndk

Use r27 or later if you target a device or emulator image with 16 KB memory pages (Android 15 and up). Earlier NDKs link binaries such an image refuses to load. CI builds with r26c, which is fine for the 4 KB images it runs.

Method 3: Direct Download

Download from the NDK Downloads page and extract manually.

Environment Setup

Set up environment variables in your shell profile (.bashrc, .zshrc, etc.):

# Android SDK
export ANDROID_HOME=$HOME/Android/Sdk  # Linux
# export ANDROID_HOME=$HOME/Library/Android/sdk  # macOS

# Android NDK (replace with your installed version)
export ANDROID_NDK_ROOT=$ANDROID_HOME/ndk/<your-ndk-version>
export NDK_HOME=$ANDROID_NDK_ROOT

# Add tools to PATH
export PATH=$PATH:$ANDROID_HOME/cmdline-tools/latest/bin
export PATH=$PATH:$ANDROID_HOME/platform-tools
export PATH=$PATH:$ANDROID_HOME/emulator

Rust Toolchain Setup

Install cargo-ndk

$ cargo install cargo-ndk --version "^3.0"

Add Android targets

$ rustup target add aarch64-linux-android
$ rustup target add x86_64-linux-android
$ rustup target add armv7-linux-androideabi
$ rustup target add i686-linux-android

Building rsbinder for Android

Basic Build Commands

# Build for ARM64 (most common for modern Android devices)
$ cargo ndk -t aarch64-linux-android --platform 29 build --release

# Build for x86_64 (emulator)
$ cargo ndk -t x86_64-linux-android --platform 29 build --release

# Build all targets
$ cargo ndk -t aarch64-linux-android -t x86_64-linux-android --platform 29 build --release

rsbinder supports API 29 (Android 10) and newer, so link with --platform 29 or higher (up to your app's minSdkVersion). Without --platform cargo-ndk links for API 21, whose libc lacks functions rsbinder calls (process_vm_readv), and the link fails with an undefined symbol.

Using envsetup.sh Helper Scripts

The rsbinder project provides a comprehensive envsetup.sh script with helpful functions for Android development:

# Source the environment setup
$ source ./envsetup.sh

Available Functions

ndk_prepare: Sets up the Android device for testing

  • Roots the device using adb root
  • Creates the remote directory on the device (/data/rsbinder by default)
  • Prepares the environment for file synchronization
$ ndk_prepare

ndk_build: Builds the project for Android

  • Reads configuration from REMOTE_ANDROID file
  • Builds both debug binaries and test executables
  • Uses cargo ndk with the specified target architecture
$ ndk_build

ndk_sync: Synchronizes built binaries to Android device

  • Pushes all executable files to the device
  • Uses adb push to 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

  1. Enable Developer Options on your Android device
  2. Enable USB Debugging
  3. Connect device via USB and authorize debugging

Running Tests

# Complete build and test workflow
$ source ./envsetup.sh
$ ndk_prepare      # Set up device
$ ndk_build        # Build binaries and tests
$ ndk_sync         # Push to device

# Test executables will be available in /data/rsbinder/

Emulator Testing

# Create and start an emulator
$ avdmanager create avd -n test_device -k "system-images;android-34;google_apis;x86_64"
$ emulator -avd test_device

# Build for emulator target
$ cargo ndk -t x86_64-linux-android --platform 29 build --release

Troubleshooting

Common Issues

"adb not found": Ensure platform-tools is installed and in PATH "cargo-ndk not found": Install with cargo install cargo-ndk "No targets specified": Add Android targets with rustup target add "Permission denied on device": Run adb root or check device permissions

Verification Commands

# Check SDK installation
$ sdkmanager --list_installed

# Check connected devices
$ adb devices

# Check available targets
$ rustup target list | grep android

# Test cargo-ndk installation
$ cargo ndk --version