Presentation: The AIDL build system — what we freeze, and how it is consumed - rdkcentral/rdk-halif-aidl GitHub Wiki

What we freeze, and how it is consumed

Background — what a consumer has to be able to do

A consumer chooses a version to write code against. You cannot implement or call an interface without picking one.

What it means
Builds against One chosen version. The code is written to that interface, so the choice is fixed at build time and changing it is a decision someone makes.
Runs against That version, or a later minor or patch of it. Within one major the protocol is backwards-compatible.

The build choice is fixed. The runtime match is a range.


What the release has to provide

Requirement Today
1 A consumer builds against a version it chooses, whatever anyone else chooses ✅ each build configuration pins its own versions manifest
2 Two consumers in one integration build against different versions of the same component ✅ across separately built layers — see the limit on one process
3 A consumer names the version it wants, and the build resolves where that version lives 🟡 today the consumer spells out the paths too
4 Client and vendor are isolated at runtime — each resolves its own libraries from its own rootfs location ❌ install half works, load half does not
5 A released <component>/<version>/ is immutable after release ❌ five commits since June edited build files inside frozen snapshots
6 The surface an engineer implements is documented, per released version ❌ ships undocumented

A client or a server implements against one specific version. The consumer states that version; the build resolves the location — sysroot, mount, directory nesting.

Integration cost, as promised to integrators: one line in a recipe, two in CMake.


One client, one vendor

Requirement 1 — a consumer builds against a version it chooses.

flowchart LR
    subgraph MW["Middleware process"]
        MWP["Bp — proxy for IVideoDecoder"]
        MWS["Bn — stub for its listeners"]
    end
    subgraph VEN["Vendor HAL component"]
        VENS["Bn — stub for IVideoDecoder"]
        VENP["Bp — proxy for the listeners"]
    end
    MWP -- "calls the HAL" --> VENS
    VENP -- "calls back" --> MWS
Loading

Both compiled from the same released snapshot — videodecoder/0.2.0.0.

Each side is a client of some interfaces and a server of others, so a binding set cannot be shipped in halves. A single HAL server exists per interface — and both layers run servers.


Many clients, one server

Requirement 2 — and the reason the client side has to be shared.

flowchart LR
    P["player<br/>Bp copy"] --> S
    R["recorder<br/>Bp copy"] --> S
    D["diagnostics<br/>Bp copy"] --> S
    S["Vendor HAL component<br/>Bn — one server<br/>self-contained binary"]
Loading
Server side Client side
One component, one server process. Nothing to share with anyone. The proxy links into every process that calls the HAL. Static would mean a copy in each, and no central patching.

Shared is the right answer on the client side, and it costs the server nothing — which is why one .so per component version serves both.


AIDL backwards compatibility — the model we are heading for

Frozen interfaces, additive only. A version is never broken, so a newer server keeps serving every client built before it.

flowchart LR
    C1["client built against V1"] -- "ok" --> S["vendor at V3<br/>V1 + V2 + V3 methods"]
    C2["client built against V2"] -- "ok" --> S
    C3["client built against V3"] -- "ok" --> S
Loading

The rule is ordering alone: server >= client.

There is no major to align, because nothing is ever removed or changed — only added. A client never sees a method disappear, so it never has to care how far ahead the vendor has gone.


Alignment — what we have today

Breaking changes are permitted, so the major has to match on both sides.

flowchart LR
    C["client built against 0.2.2.0"]
    C -. "unsupported — major not aligned" .-> A["vendor 0.1.x.x"]
    C -. "unsupported — vendor behind client" .-> B["vendor 0.2.1.0"]
    C -- "ok — exact" --> D["vendor 0.2.2.0"]
    C -- "ok — vendor ahead" --> E["vendor 0.2.3.0"]
    C -. "unsupported — major not aligned" .-> F["vendor 0.3.x.x"]
Loading

The rule is alignment plus ordering: same major and vendor >= client.

Vendor ahead within the major Supported. The client's methods are all still there.
Vendor behind the client Unsupported. The client may call what the vendor does not implement.
Major not aligned, either direction Unsupported runtime configuration. Rejected, not degraded.

halcompat::isCompatible() applies whichever rule the era calls for, so a mismatch is reported rather than discovered by crashing.


Why the requirements are shaped this way

Ours today, against the model we are heading for.

Ours today — era 0 Android stable AIDL
Breaking change Allowed, with a major bump Never, once frozen — additive only
Client ÷ vendor Must share a major Independent — any frozen version coexists
Versions built Those the cohort links Every frozen version, always
Supported range Implied by the cohort Declared in the VINTF compatibility matrix

Android buys coexistence by giving up the right to break. We have not paid that price yet — every component is still 0.x and breaking changes are the expected output of the review cycle. Alignment is the cost of still being able to change the interfaces.


What the build has to produce

Requirements 1, 2 and 4 — the artefacts that make them true.

One shared library per component version, carrying both roles. add_library(... SHARED ...) is what the snapshots build today, so lib<comp>-v<ver>-cpp.so already is the default.

Bp and Bn are one translation unit A binding set is not separable, so one library serves the client and the server sides alike.
A static .a for the server Possible, and not required. The .so is fine for both, and it is what already gets built.
The version is in the library name Which is how several versions occupy one directory, and how the loader tells them apart.

Android reaches the same place — libbinder_ndk is shared, and stub libraries carry the version in the name.


Is one process with two versions a problem?

Not today. The released cohort is uniform — every importer of a shared component agrees on its version:

Importers of common All on
audiodecoder, audiomixer, audiosink, avclock, compositeinput, hdmicec, hdmiinput, hdmioutput, panel, planecontrol, videodecoder, videosink [email protected]
avbuffer, panel, planecontrol, videosink → videodecoder [email protected]

One version of each component exists in the cohort, so no process can hold two.

Neither of our isolation mechanisms is what protects this. Separate rootfs locations divide middleware from vendor, and major alignment governs client against server — both are across processes. This case is two components inside one process sharing a dependency.


What it would cost if the cohort stopped being uniform

Two versions of a component export identical symbols — the C++ namespace carries no version, and nothing is compiled hidden:

libvideodecoder-v0.2.0.0-cpp.so   _ZN3com...12videodecoder13IVideoDecoder19getInterfaceVersionEv
libvideodecoder-v0.2.0.1-cpp.so   _ZN3com...12videodecoder13IVideoDecoder19getInterfaceVersionEv

Load both and the first one wins, for everybody — no error:

Link order Caller asked for Got
0.2.0.1 first 0.2.0.1 0.2.0.1
0.2.0.0 first 0.2.0.1 0.2.0.0

This is the tension in requirement 2. What is delivered today is different versions across separately built layers. Different versions within one process is a further step, and the symbol model does not support it.

Ask the room: do we commit to a uniform cohort and state it as a constraint — or make symbols version-distinct, and pay for it in the generator?


Isolation between the layers

Requirement 4 — and the one not yet met.

flowchart TB
    subgraph NOW["Today — no RUNPATH"]
        A1["MW binary"] --> X["one libbinder.so<br/>whichever the loader finds"]
        A2["vendor binary"] --> X
    end
    subgraph FIX["With RUNPATH per artifact"]
        B1["MW binary"] --> Y1["/mw/usr/lib<br/>its own libbinder"]
        B2["vendor binary"] --> Y2["/vendor/usr/lib<br/>its own libbinder"]
    end
Loading

Two builds of the same source, same library name, same symbols — separated by where they are installed and where they look.

  • The install half already works. Setting the prefix gives two complete trees — library, headers and servicemanager — under /mw/usr and /vendor/usr.
  • The load half does not. No RUNPATH is emitted anywhere, so resolution falls to the loader's default path.

Isolation — what it takes

Requirement 4, continued.

DT_RUNPATH is not transitively inherited. A middleware service carrying RUNPATH=/mw/usr/lib does not help libbinder.so find its own liblog, libbase, libcutils and libutils — each of those needs its own.

Why not DT_RPATH And a standing rule
It is inherited — but LD_LIBRARY_PATH overrides it, which with two libbinders present is worse than the problem it solves. Neither prefix may go in /etc/ld.so.conf.d/. If it does, the ldconfig cache order decides — unstable across images.

Without this the failure is silent: both stacks load the same library, and nothing reports an error.

Fixed by linux_binder_idl PR #63, merged 22 September (#62).

⚠️ **GitHub.com Fallback** ⚠️