# proot, glibc and where the time goes Research notes for `feat/performance-fixes-2` (2026-09-30). The question was whether Steam and the games should keep running under proot, run directly on glibc, or a mix of the two, and what the fastest correct setup is. Everything below was measured on a Snapdragon 8 Elite Gen 5 (SM8850, kernel 6.12.38-android16, Android 16) over adb, unless it says otherwise. For Steam SD allocation measurements on AYN Thor (Android 13), see the [Thor storage investigation](thor-sd-storage-investigation.md). It separates observed allocation-wrapper time, FUSE directory access and native exFAT metadata waits; it does not establish a full-install PRoot overhead percentage. ## Short answer - **Don't drop proot.** On Android it does two jobs. It translates paths. It also answers every syscall that Android's app seccomp policy blocks (`rseq`, `set_robust_list`, `faccessat2`, `fchmodat2`, `openat2`, the `set*id` family, `futex_waitv`, ...). - Stock Arch glibc without a tracer is killed by `SIGSYS` on its first `pthread_create`. That stays true even with an in-process `SIGSYS` handler, because glibc makes those calls with every signal blocked. - Going "pure glibc" means building Termux-style glibc (relocated prefix, blocked syscalls rewritten), an exec shim for every ELF Steam self-updates, a `/proc/self/exe` emulator for Chromium, path redirects for `/tmp`, `/dev/shm`, `/mnt/*` and the fake `/proc` and `/sys` files, and Wine's hard-coded `/tmp/.wine-`. - That is a project of its own, and its failure mode is a crash instead of a slow call. - **During gameplay, proot already costs almost nothing**, thanks to patches 0004–0013. GPU submits (`ioctl`), `futex`, `read`/`write`, `mmap`, `ppoll`/`epoll`, `sendmsg`/`recvmsg` and `clock_gettime` (via the vDSO) all run at native speed under the filter. - **What proot still costs** is every *path* syscall, `execve`, `brk`, thread and process creation, and every Android-blocked syscall: **15–60 µs each**, against ~0.5 µs natively. All of it goes through **one tracer thread**, which tops out around 75–95k traps/s. Steam start-up, game loading and asset streaming, Proton's Python, and any burst on several threads at once all hit this. - **Most of that cost is not proot's code but the two cross-core wake-ups per trap.** Any out-of-process mechanism pays them: ptrace, and seccomp user-notification too (measured the same). The only real fix is to not leave the process. - **Recommendation: proot plus an in-process fast path.** A preloaded library answers the common path calls inside the process. It sends them through a trampoline page that proot's filter lets through, and it falls back to proot for anything it can't prove proot would answer the same way. - The prototype is in this branch: `tools/proot/fastpath/` and `patches/0014`. - Speed: `stat` 25 µs → 0.6 µs, `open` 25 µs → 0.9 µs, missing-file lookups 23 µs → 1.6 µs. Eight threads doing `stat` went from 89k/s to 3.6M/s. - Correctness: an equivalence suite of awkward paths produced byte-identical results. - It only uses syscalls the app policy allows. ## How a session runs today ``` SessionService ─HostProcess─▶ libproot.so (ONE tracer process, nice -6 after 2 s) └─ /usr/bin/env -i … droiddeck-session steam └─ gamescope ─▶ droiddeck-session (BL_INSIDE) ─▶ steamrtarm64/steam (+ steamwebhelper/CEF tree) └─ reaper ─▶ droiddeck-game-env ─▶ droiddeck-proton ─▶ Valve ARM64 Proton (python) └─ wine (arm64ec, FEX loaded in-process as a DLL) ─▶ wineserver, game.exe, … ``` - Every process in the tree, from the session script to the game, is a tracee of the same proot. - The options are `--kill-on-exit`, `--kernel-release=…` (kompat, for the hostname), `-i uid:uid` (fake_id0, for Xwayland's `setgid`/`setuid`), and about 25 binds: `/dev`, `/proc`, `/sys`, fake `/proc` and `/sys` files, the GPU node, `/dev/shm`, storage and the game libraries. - `/etc/ld.so.preload` loads `libblsession.so` (the `tools/linuxfs/preload/*.c` shims) into every guest process. - The Flatpak sandboxes (`BwrapSpawner`) already run in **separate** proot instances, started from the Android side. ### What proot does per traced syscall The tracee runs into proot's seccomp filter, which returns `SECCOMP_RET_TRACE`. Then: 1. The kernel stops the tracee and wakes the tracer: a cross-core IPI, and possibly an exit from a deep idle state. 2. The tracer reads the registers (`PTRACE_GETREGSET`) and reads the path (`process_vm_readv`). 3. It canonicalises the path against the rootfs and bindings: an `lstat` per component, or one `O_PATH` open plus a `/proc/self/fd` readlink with patch 0002. 4. It writes the host path into a scratch area in the tracee (`process_vm_writev`) and updates the registers. 5. It resumes the tracee, which is another cross-core wake-up. Calls flagged `FILTER_SYSEXIT` stop a second time on the way out, for example to translate the result of `readlink` or `getcwd`. Android-blocked syscalls arrive as a `SIGSYS` signal stop instead, and proot emulates them (`src/tracee/seccomp.c`). Fork, clone, exec and thread exit each add `PTRACE_EVENT_*` stops. ## Measurements The tools are in `tools/proot/bench/`; `run-device.sh` explains the device layout. **Caveat:** `adb shell` is *not* under the app seccomp filter. Mechanism costs are the same, but syscalls that Android blocks only for apps (see below) can't be reproduced there. **Caveat:** the phone in Doze (screen off) runs everything about 7× slower. All numbers below were taken with the screen awake. ### Per call, as a session runs proot today `sysbench` in the patched (`patched`) and unpatched (`vanilla`) columns, using the session's own proot options and a similar set of binds. | call | direct glibc | termux proot (vanilla) | this repo's proot (0001–0013) | + fast path (0014) | |---|---|---|---|---| | getppid / futex / ioctl / pread (untraced) | 0.1–0.4 µs | futex **16.6**, fstat **32.8**, ioctl **30.5** µs | 0.2–0.4 µs | 0.2–0.4 µs | | stat, deep absolute path | 0.8 µs | 41.7 | 25.3 | **0.6** | | open + close | 1.7 µs | 40.4 | 25.3 | **0.9** | | access | | 27.2 | 26.2 | **1.0** | | fstatat relative to a dirfd | | 45.5 | 26.8 | **1.9** | | stat of a missing file | | 38.9 | 22.9 | **1.6** | | readlink /proc/self/exe | | 42.8 | 38–43 | (proot) | | getcwd / uname / brk | | 31–35 | 30–33 | (proot) | | memfd_create | | 20.4 | 18.6 | (proot) | | pthread create+join | | 58 | 48 | 48 | | fork + exec + wait | ~1.1 ms | 1.16 ms | 0.70–0.78 ms | +0.2–0.4 ms (see open items) | The patches already in the repo pay off: kompat (0011) and fake_id0 (0012) took `futex`, `fstat` and `sendmsg` off the trap list, and 0013 did the same for `ioctl`. ### What one trap costs, by mechanism (`mechbench`; the handler does nothing) | mechanism | cost | |---|---| | native syscall | 0.1 µs | | seccomp filter of 0 / 50 / 150 / 300 compares, allowed syscall | **no difference** (kernel ≥5.11 caches constant decisions per syscall number) | | ptrace + seccomp, tracer and tracee on the **same core** | 6.2 µs (6.7 µs with register get/set) | | ptrace + seccomp, **unpinned** (as the app runs it) | 15.5 µs | | ptrace + seccomp, different cores (same cluster / across clusters) | 34–36 / 42–61 µs | | ptrace with a spinning tracer, different cores | 20 µs (the tracee's side still has to wake) | | seccomp user-notification (the "modern proot" design) | 5.7 µs same core, 34–58 µs across, 43 µs unpinned; `SYNC_WAKE_UP` doesn't help | | **in-process SIGSYS** (`SECCOMP_RET_TRAP`) | **0.83 µs** | | openat2 `RESOLVE_IN_ROOT` (kernel-side chroot lookup) | 1.0 µs, but **blocked in apps** | Takeaways: - The tracer's own work is about 8 µs of the ~14 µs same-core trap. Everything above that is scheduling. - Moving to seccomp user-notification would be a rewrite for no gain. - The filter's length doesn't matter. What matters is not breaking the kernel's cache: - **Any check on an argument or the instruction pointer must come after the syscall-number dispatch.** The first version of 0014 checked the address first. That made every syscall in the session run the whole filter, and cost +0.5 ms per exec. - Patches 0004 and 0013 already follow this rule. ### One tracer for everything (`parstat`: N threads doing `stat`) | | 1 thread | 4 threads | 8 threads | |---|---|---|---| | one proot | 37k/s, 27 µs | 74–95k/s, 42–54 µs | 68–89k/s, **90–118 µs** each | | two proots, 2 threads each | | 187k/s total, 21 µs | | | proot + fast path | 1.64M/s, 0.6 µs | 2.76M/s | **3.60M/s, 2.2 µs** | A burst of path calls on several threads therefore queues behind the single tracer. CEF loading assets, Proton's Python, a game streaming files on worker threads and the shader-cache threads all produce such bursts. ### CPU placement The workload ran with `taskset`; no fast path. Times are per traced call, and the last column is one fork + exec. | affinity | stat | exec | |---|---|---| | default | 25.8 µs | 750 µs | | one core | 14.0 µs | 370 µs | | the two prime cores | 14.6 µs | | | the six-core cluster | 25.9 µs | | The scheduler's wake-affine placement helps when it can put the tracer and the tracee together, and spreading them apart hurts. **Never pin the tracer away from its tracees.** Today `raiseTracer` only renices it, which is correct. ### Whole programs Times are in ms, each with proot's ~40 ms start-up already subtracted. | | direct glibc | vanilla proot | this repo's proot | + fast path | |---|---|---|---|---| | python3: 10 stdlib imports | 75–89 | ~130 | ~110 | ~90 (878 of 879 path calls answered in-process) | | tar of 2.7k files | 10–40 | ~180 | ~80 | ~20 | | 200 × fork+exec of `true` | 230 | ~600 | ~500 | ~500 | | find over 2.7k files | 16–23 | noisy | noisy | unchanged: glibc's fts calls internal entry points | ## Android's app seccomp policy decides more than proot does The app filter is generated from bionic's `SYSCALLS.TXT`, minus the blocklists, plus the allowlists. It is the same for every app whatever its `targetSdk`, and anything not listed gets `SECCOMP_RET_TRAP`, a `SIGSYS`. Filters stack, and the strictest one wins: a blocked call never reaches proot's `RET_TRACE`. It arrives as a `SIGSYS` stop, and proot answers it (usually `ENOSYS`). Checked against bionic `android16-release`: | syscall | app policy | who calls it | |---|---|---| | `rseq`, `set_robust_list` | **blocked** | glibc, at the start of every thread, while all signals are blocked | | `faccessat2` | **blocked** | glibc's `faccessat()`, which tries it on **every** call before falling back, so 2 round trips (`access()` calls the plain `faccessat` syscall directly) | | `fchmodat2`, `openat2` | **blocked** | newer glibc, Flatpak/libglnx, FEX's rootfs lookups (`libblsession.so` turns `openat2` into `openat`) | | `setuid`, `setgid` and the rest of the family | **blocked** | Xwayland (hence `-i`) | | `futex_waitv`, `io_uring_*`, `landlock_*` | **blocked** | Proton's fsync (droiddeck-fsync answers `futex_waitv` in `libblsession.so`), some engines | | `clone3` | blocked before Android 15 | glibc `pthread_create` | | `close_range` | blocked on Android 12 | | | `statx`, `process_vm_readv`/`writev`, `memfd_create`, `pidfd_*`, `seccomp` | allowed | | On top of the ptrace round trips, every one of these costs a signal round trip through proot. ## Every traced syscall: what it needs and whether it can bypass proot This is the filter of termux proot at `source.env` plus patches 0001–0013. "Fast path" means the call can be answered in-process with the 0014 design. | syscalls | why proot traps them | can it bypass proot? | |---|---|---| | `openat`/`open`/`creat`, `newfstatat`/`stat`/`lstat`, `statx`, `faccessat`/`access`, `readlinkat` (exit), `mkdirat`, `unlinkat`, `renameat(2)`, `symlinkat`, `linkat`, `fchmodat`, `fchownat`, `utimensat`, `truncate`, `*xattr`, `inotify_add_watch`, `name_to_handle_at` | path translation (guest → host), plus exit fixups for `readlink` and `rename` | **Yes, through libc wrappers.** The prototype covers open, stat, statx, access, readlink, opendir and fopen; the write family is next. Raw `svc` and glibc's internal callers (ld.so, fts, realpath, nss) still go to proot. | | `chdir`/`fchdir`, `getcwd` (exit) | proot emulates the cwd; until 0014 the kernel's cwd never moved | With 0014 the kernel's cwd follows the guest's. `getcwd` stays in proot (cheap, rare). | | `execve`/`execveat` | runs the loader, maps `PT_INTERP` inside the rootfs, shebangs, tracks `/proc/self/exe` | **No.** This is the core of what proot is for. Costs 0.4–0.8 ms per exec; the session scripts already avoid exec-heavy loops (`nap`). | | `brk` (entry and exit) | heap emulation: programs are mapped by proot's loader, so the kernel's brk area is the loader's | No (`PR_SET_MM` needs `CAP_SYS_RESOURCE`). **Fewer calls:** `GLIBC_TUNABLES=glibc.malloc.top_pad=…` grows the heap in larger steps. | | `bind`, `connect`, `accept(4)`, `getsockname`, `getpeername` | `sun_path` of Unix sockets | Same-path binds (runtime dir, files dir) need no translation, but the filter can't see the address. Possible fast-path candidate. | | `wait4`/`waitpid`, `ptrace` | ptrace emulation inside the guest (breakpad, gdb) | Could become opt-in; then `wait4` is free (one stop per wait today). | | `prctl(PR_SET_DUMPABLE)`, `setrlimit`/`prlimit64(RLIMIT_STACK)` | loader and stack fixups | Already narrowed to these arguments (0004). | | `ioctl(TCSETSF, termios2, FICLONE)` | Android pty policy, FICLONE `EACCES` | Already narrowed to these requests; GPU ioctls are free (0013). | | `uname`, `sethostname`, `setdomainname` | the `DroidDeck` hostname (kompat) | Rare. Keep. | | `set*id`, `*setxattr` | fake_id0 identity mode (0012) | Rare. Keep. | | `memfd_create` | Qt JIT and php workarounds (string argument) | Every Wayland `wl_shm` buffer and Chromium shared memory pays one stop. Could be dropped if the DroidDeck runtime doesn't need those workarounds. | | `statfs` (exit) | fakes tmpfs for `/dev/shm` | Rare. | | `open_tree`, `move_mount`, `fspick`, `mount_setattr`, `openat2` | answered `ENOSYS` (0008, 0010) | Fine. | | Android-blocked calls (previous table) | `SIGSYS` emulation | **Avoidable at the source:** `GLIBC_TUNABLES=glibc.pthread.rseq=0` removes one stop per thread. The rest need a patched glibc. | | clone/fork/vfork/exec/exit events | tracking tracees | Needed while proot traces. | ## Options weighed 1. **Pure glibc, no proot.** Rejected as the main path, for the reasons in the short answer. - Closest prior art: huntergdavis/steamclienttermux, which runs `steamrtarm64` on a patched Termux glibc 2.44 with an exec shim and a `/tmp` and `/dev/shm` redirect preload. - They report Steam's first window 7× faster and about 5% more FPS, with proot having used 60–65% of a core. - **They still run Proton and the games under proot.** - DroidDeck also has four package names (`tools/release/variants.txt`), so a relocated prefix would need four builds or Winlator-style padded path rewriting. 2. **Hybrid by process (Steam in proot, game on glibc, or the reverse).** The game side is the *harder* half. Wine re-execs itself and `wineserver`, which hits `PT_INTERP`. Its server directory is hard-coded under `/tmp`, and Proton's esync and fsync use `shm_open` (`/dev/shm`) and `futex_waitv`, which is blocked (droiddeck-fsync emulates it in the session preload, which a glibc-side game would have to load too). A game's steady state is already mostly untraced, so the gain is in loading. Not worth it before the fast path. 3. **Seccomp user-notification instead of ptrace.** It measures the same as ptrace, and it can't rewrite arguments, so every call would have to be emulated. No. 4. **In-process `SIGSYS` for everything** (0.8 µs). The kernel resets a blocked `SIGSYS` to the default action and kills the process (`force_sig_seccomp`, `HANDLER_CURRENT`). glibc and Wine block all signals around exactly these calls. Unsafe without also trapping `rt_sigprocmask`. No. 5. **proot plus an in-process fast path (this branch).** Recommended. Details below. 6. **A proot-aware glibc**, the follow-up to 5. Rebuild the rootfs's own glibc (same version, same Arch package) with two changes: - its internal path entry points (`__open_nocancel`, `__fstatat64`, ld.so's opens) go through the fast path, which covers ld.so, fts, realpath and nss; - it never makes the Android-blocked calls, as Termux's `fakesyscall.json` does. This removes the remaining library-loading traps and all the `SIGSYS` round trips. The rootfs is versioned (`linuxfs-rN`), so glibc rebuilds stay under control. 7. **On the tracer side, with no in-process changes:** - **A second proot for the game tree.** Measured 2.5× trap throughput, and Steam/CEF bursts no longer stall the game. `BwrapSpawner` already shows the pattern: a stand-in process in the session relays stdio, the exit status and signals to a proot started from the app. - Leave the tracer unpinned (above). - Set `GLIBC_TUNABLES=glibc.pthread.rseq=0:glibc.malloc.top_pad=16777216` in the session environment. ## The fast path (prototype) The code is `tools/proot/fastpath/fastpath.c`, preloaded into every guest process. On the proot side it needs `patches/0014`, enabled with `PROOT_FASTPATH=1`. - **Trampoline.** The library maps a 4 KiB executable page at the fixed address `0xffff00000`. It holds `mov x8,x0 … svc #0; ret`. With `PROOT_FASTPATH`, proot's filter checks the caller's address just before each `RET_TRACE` and allows syscalls coming from that page. - **Mapping a path.** The library takes the literal guest path and finds the longest binding whose guest path is a prefix of it; otherwise the path is under the rootfs. - It opens the *parent* host directory `O_PATH` and requires `/proc/self/fd` to name exactly that path. That proves no component on the way was a symlink, so proot would have walked the same directories. - The last component is never followed: a symlink there goes to proot. - **Missing files.** For a missing file, the deepest existing ancestor is verified the same way, and the next component must not exist even as a symlink. Then `ENOENT` is exact. - **What always goes to proot:** - `..`, a trailing `/`, `/proc`, `/dev/fd` and `/dev/std*`; - `O_TMPFILE`, `AT_EACCESS`, and any flag not handled; - any doubt at all. "Go to proot" means calling the real libc function, so proot decides. - **Relative paths.** Patch 0014 keeps the kernel's cwd at the guest cwd's host directory. Relative and dirfd lookups are mapped through `/proc/self/{cwd,fd/N}`. - **Verified-parent cache.** Verified parents are cached for 2 s (`PROOT_FP_TTL_MS`). That is the same trade-off `preload/pathcache.c` already makes for the Steam client. A parent directory turned into a symlink inside that window would be missed; `PROOT_FP_TTL_MS=0` verifies every call, at 2.0 µs instead of 0.6 µs. - **Allowed syscalls only.** It uses only `openat`, `newfstatat`, `statx`, `faccessat`, `readlinkat` and `close`, all allowed for apps. A first version used `openat2 RESOLVE_IN_ROOT`, which the app policy blocks. - **Equivalence check.** `bench/equiv.py` runs stat, lstat, readlink, read, listdir and access over awkward paths, from several working directories and through a dirfd. Its output is byte-identical with and without the fast path, at TTL 0 and at TTL 2000. The paths include: - absolute and relative symlinks into binds, `..` symlinks, dangling links and loops; - files inside binds, nested binds (`/dev/shm` inside `/dev`) and `/proc`; - missing paths behind symlinks. ### Open items before shipping it - **Validate inside the app**, not just from adb. Three things to check: - The app domain allows an anonymous `PROT_EXEC` page (`execmem`). If not, map a page of the library file itself at the fixed address instead, as proot's loader does for every program. - Nothing claims `0xffff00000` first. - The stub's syscalls pass Android's filter. They should: all are allowed. - **Integrate into `libblsession.so`** rather than a second preload. `opens.c` and `pathcache.c` already define `open` and `access`, so their "real" call should become the fast path. That also removes the second library's load cost, about 0.2–0.4 ms per exec that is still unexplained (see the exec row above). - **Wire the launcher.** The app already knows the binds (`LinuxRuntime.binds`): - set `PROOT_FASTPATH=1` in proot's environment; - pass `PROOT_FP_ROOT` and `PROOT_FP_BINDS` to the guest; - add a Performance toggle next to "Run proot without seccomp", with the fast path off whenever seccomp is off. - **Cover the write family** (mkdir, unlink, rename, symlink, chmod, utimens) and Unix-socket `connect`/`bind` for same-path binds. Then measure a real Steam start in the app, a game load, and `PROOT_FP_STATS` hit rates for steam, steamwebhelper, wine and wineserver. - **FEX**, inside Proton as a DLL, translates x86 path syscalls through glibc's `syscall()`, not the wrappers, so they still trap. A `syscall()` hook could route `openat` and `newfstatat` there too.