37 KiB
proroot: what it does and what DroidDeck can take from it
Static analysis of coderredlab/proroot v1.2.8
(c01039ab, 2026-06-17). The repository holds only five arm64 binaries and a README. The licence is
proprietary, and redistributing modified binaries is not allowed, so only the ideas below are
usable, not the code. The binaries were read with the NDK's llvm-readelf, llvm-nm,
llvm-objdump and llvm-strings. libproroot-stub-loader.so and libproroot-bridge.so still carry
.symtab, and libproroot-runtime.so exports about 260 names, so the function names below are
proroot's own.
Architecture
proroot does not use ptrace. Everything runs inside the guest process.
| file | kind | role |
|---|---|---|
libproroot.so |
bionic PIE executable | Launcher. Parses -r/-b/-w/-0/--link2symlink, writes a config file (.proroot-config-<pid>, passed down as PROROOT_CFG_FD), and execs the guest through libproroot-linker.so or the stub loader. |
libproroot-linker.so |
freestanding PIE, run as an executable | A from-scratch glibc dynamic linker ("cleanroom linker") that replaces ld-linux-aarch64.so.1. It maps the program and its DT_NEEDED libraries, relocates them, sets up static TLS, and stubs _dl_* and __tunable_get_val. It finds _rtld_global_ro through a table of offsets per glibc version ("no offset table for glibc X, patches skipped"). It also installs a SIGSYS handler with an allow-list. |
libproroot-stub-loader.so |
freestanding static PIE | Userland exec. It maps the target and its PT_INTERP itself and builds the stack and auxv. It then rewrites the svc #0 sites in the loaded images (proroot_svc_scan, proroot_svc_patch_apply, select_trampoline_for_site) to branch into per-syscall trampolines (proroot_tramp_openat, _statx, _faccessat2, _clone3, _rt_sigaction, _shmget, ...). Sites it cannot patch fall back to an in-process seccomp RET_TRAP filter with a SIGSYS handler ("trampoline fallback, activating trap-mode fallback"). |
libproroot-runtime.so |
glibc LD_PRELOAD |
Hooks about 200 libc entry points: path translation, fake root (fake_id0_*), link2symlink, exec routing, SysV shm, netlink, /proc and /sys views, brk, a fake DRM/KMS and GBM device plus libseat for compositors, X11 and Unix-socket fixups. After load it patches the svc sites in glibc itself (patch_libc_syscalls). |
libproroot-bridge.so |
static ET_EXEC at 0x7000000000 |
The "trampoline" for static guest binaries. It installs a path-trap seccomp filter plus a SIGSYS handler, maps the target, and serves a virtual /proc (status, stat, cmdline, environ, mounts, mountinfo, fdinfo, auxv). |
The executable files all live in nativeLibraryDir, which is the only place an app may exec from.
That is why the dynamic linker and the static loader each have their own userland loader. proot
handles the same constraint with libproot-loader.so.
How the in-process syscall interception works
- Patching svc sites in glibc (
patch_phdr_callback, called throughdl_iterate_phdr):- It scans only an allow-list of objects:
libc.so.6/libc-,ld-linux,libpthread,librt,libdl. Main executables get patched early only for listed programs (apt, dpkg, curl, openssl, node, chromium/electron/playwright, passwd/useradd/...). Other programs are patched later (log_deferred_libc_patch,PROROOT_FORCE_EARLY_PATCH). - In each executable
PT_LOADit findssvc #0(0xd4000001). It checks the instructions before it for amov x8, #nrit recognises. One fixed glibcopenatcall sequence gets special treatment: itsblis redirected to a relay (emit_openat_call_relay_entry). - The site is replaced with a
b/blto a relay stub on a page allocated within ±128 MiB (alloc_relay_page). Sites out of range are logged asbl-out-of-range;large-image multi-cover trampplaces extra relay pages for big images. - Write path: it first tries
mprotect(PROT_READ|PROT_WRITE|PROT_EXEC)on the segment. If that is refused, itpwrites the 4-byte instruction through/proc/self/mem. Either way it then calls__clear_cache. The log line counts sites by kind:patched %d seccomp + %d path (%d relay) + %d brk.
- It scans only an allow-list of objects:
- In-process SIGSYS fallback:
- A BPF filter allows syscalls whose instruction pointer is inside proroot's own code and
returns
SECCOMP_RET_TRAPfor the path, exec, clone and wait syscalls from anywhere else. The IP check is the same idea as our patch 0014, with TRAP instead of TRACE. - The handler emulates the call and writes the result into the signal frame. Guest SIGSYS
handlers are kept and chained (
proroot_record_user_sigsys,invoke_user_sigsys).
- A BPF filter allows syscalls whose instruction pointer is inside proroot's own code and
returns
- Keeping SIGSYS deliverable, the objection in option 4 of
proot-performance:
- The
sigprocmaskandpthread_sigmaskhooks callsigtrap_unblocked_mask, whichsigdelsetsSIGSYS(andSIGTRAPfor some guests) from anySIG_BLOCK/SIG_SETMASKset. rt_sigactionis guarded so the guest cannot replace the handler (proroot_tramp_rt_sigaction, thesigaction_guardtrace points).- The raw
rt_sigprocmaskthat glibc makes internally, for examplepthread_createblocking every signal, goes through a patched svc site. The stub loader and the runtime both compare against syscall 135.
- The
- Android-blocked syscalls are answered in-process. The stub loader dispatches
set_robust_list,rseq,faccessat2,clone3,futex_waitvandseccomp. The runtime emulates SysV shm (emu_shm*),set*id/get*idunder-0, andbrk(emulate_guest_brk, over a reserved mapping).
Compared with DroidDeck
DroidDeck (proot + 0001–0018 + libblfastpath.so) |
proroot | |
|---|---|---|
exec, PT_INTERP, /proc/self/exe |
proot's loader under ptrace, 0.4–0.8 ms per exec | Its own loaders, with no tracer; also needs its own ld.so with a glibc offset table |
| path calls through libc wrappers | fast path, about 0.6–1.9 µs | runtime hooks |
path calls from glibc internals (ld.so, fts, realpath, nss, __open_nocancel) and raw svc |
still go to proot (15–60 µs) | patched svc sites |
Android-blocked syscalls (set_robust_list, faccessat2, clone3, ...) |
SIGSYS stop answered by proot (0016 etc.), one ptrace round trip and one signal round trip each | in-process |
brk |
proot emulation, a trap per call (mitigated by top_pad) |
in-process emulation |
| correctness fallback | proot is always there | limited in-process seccomp/SIGSYS handling; no general tracer fallback, so calls outside the filter can run unconfined or be emulated incorrectly |
| long Unix socket paths | fast path /proc/self/fd/N/name aliases |
short socket aliases (proroot_create_short_unix_socket_alias) |
| rseq | GLIBC_TUNABLES=glibc.pthread.rseq=0 |
trampoline |
proroot is not a model for replacing proot. It has no tracer to fall back on, so a missed call site or an unexpected glibc build silently breaks isolation or correctness. Its own linker depends on glibc internals for each version. Proton, Wine, FEX and Steam's self-updating ELFs are a much wider surface than the Node, Python and Chromium workloads proroot was tested on. Its value for DroidDeck is as a fast path in front of proot.
What is worth taking
- Patch glibc's svc sites from
libblfastpath.so's constructor. This is the largest gain and the cheapest way to do option 6 in proot-performance ("a proot-aware glibc") without rebuilding glibc.- For
libc.so.6(andld-linux-aarch64.so.1for runtimedlopen), rewrite eachsvc #0that follows a recognisedmov x8, #nrfor the path syscalls the fast path already handles. The rewrite is abto a relay that calls the existing translator. - The relay issues the syscall from the
0xffff00000trampoline page, which 0014 already lets through. Anything the translator declines is re-issued from an svc in the relay page, outside the trampoline, so proot traps it exactly as it does today. Correctness still rests on proot. - This covers fts (
findover 2.7k files, unchanged today), realpath, nss,__open_nocancel, anddlopenafter start-up. - Library loading at start-up, before the constructor runs, still traps.
- For
- Answer the Android-blocked syscalls through those relays.
set_robust_listandget_robust_list: keep the head in TLS, as 0016 already does in proot.faccessat2: answerENOSYS, or translate it when flags are 0, as the wrapper does.clone3: answerENOSYSbefore Android 15, so glibc falls back toclone.futex_waitv: droiddeck-fsync already handles this.- Each one removes a SIGSYS stop plus a ptrace round trip, about 30–50 µs.
set_robust_listalone is one per thread, which matters for Steam/CEF and Wine thread churn.
- The
/proc/self/memwrite as a fallback for patching. If the app domain refusesmprotect(PROT_WRITE|PROT_EXEC)on rootfs (app_data_file) text, i.e. anexecmoddenial, a write through/proc/self/memmakes the page copy-on-write without anmprotect. That page is never writable and executable at once. Check this on the Thor before relying on it. The same question is still open for the trampoline page (execmem). - Keep SIGSYS unblocked if in-process
SECCOMP_RET_TRAPis ever used. StripSIGSYSin thesigprocmask/pthread_sigmask/rt_sigprocmaskpaths, and keep the handler installed while chaining the guest's. This only becomes safe once item 1 covers glibc's internalrt_sigprocmask. It is a prerequisite, not a recommendation to switch. - Cache patch sites by build-id. proroot rescans each image on every exec and defers patching
for most programs to keep exec cheap. A scan of libc's roughly 1.5 MiB of text is cheap, but a
per-build-id site table, written once in
/tmpby the fast path, would make it free.
Not worth taking: the cleanroom ld.so and its glibc offset table; the userland exec loader (proot
already does this); the fake DRM/GBM/libseat device (DroidDeck has its own compositor path);
netlink and SysV shm (netif.c and sysv.c already cover these); and the long-socket aliasing
(the fast path covers it).
Open questions before prototyping item 1
- Does the app domain allow
mprotect(RWX)on a private mapping of anapp_data_file? Does a/proc/self/memwrite work under the app's SELinux context and proot's ownptraceattach?/proc/self/memwrites needPTRACE_MODE_ATTACHon self, which is always allowed. - How many
libc.so.6path syscall sites fall outside a recognisablemov x8, #nrwindow? For example,syscall()takes the number in a register, which needs the existingsyscall()hook. - Patched pages become private copies, about 4 KiB per patched page per process. Measure RSS across the Steam/CEF tree.
- Equivalence: run
bench/equiv.pyplus afind/realpath/nss workload withPROOT_FP_STATS. Compare the trap counts from patch 0018 before and after.
First implementation and Thor validation (2026-10-27)
tools/proot/fastpath/svc-patch.h, included by fastpath.c, implements the first
slice of item 1. It runs automatically in fresh processes using the replacement
libblfastpath.so when the ordinary fast-path configuration and tracer-key check
succeed. Set PROOT_FP_PATCH_SVC=0 to disable it for troubleshooting or comparisons;
PROOT_FP_PATCH_SVC=1 also explicitly enables it. It skips processes that already have multiple threads,
and declines SVE/SME hardware because the relay preserves the base ARM64 register state.
The scanner visits executable segments of the loaded libc.so.6 and
ld-linux-aarch64.so.1. It only patches openat and newfstatat sites with an
unshifted movz x8/w8, #nr within the preceding eight instructions. Intervening
instructions must belong to a small allow-list of argument moves/sign-extension,
immediate moves, stack loads/stores and NOPs that leave x8 unchanged. Branches,
calls, x8 writes and unknown instructions stop recognition. This includes libc's
non-adjacent O_CREAT sequences. Register-number dispatchers and other syscall
numbers remain under PRoot. No glibc version offsets, proroot code, or modified
proroot binaries are used.
Each replacement is a four-byte b into a nearby relay. Relay pages are filled while
writable, then changed to RX before publishing any branch. Text writes use
/proc/self/mem; failure leaves the site unpatched. The relay preserves x1–x30, all
32 SIMD registers, NZCV, FPCR/FPSR and the caller's errno, returning a raw kernel-style
result in x0. It calls the existing path translator, using the existing allowed
trampoline for translated calls. Declined and recursive calls execute an ordinary
svc in this library, where PRoot still intercepts them. No SIGSYS handler or new
seccomp filter is installed. PROOT_FP_STATS=1 adds fastpath-svc exit counters.
The connected AYN Thor was running runtime r9, glibc 2.43, with a 4 KiB page size in
u:r:untrusted_app_27:s0. bench/svc-probe.c, run through the app's guest bridge,
confirmed all of the following while PRoot was attached:
- An RX private mapping of the rootfs libc accepted
mprotect(RWX). /proc/self/memaccepted a changed byte in that RX mapping; readback and restoration succeeded, and the backing file stayed unchanged.- An anonymous RW mapping could be changed to RX.
- libc contained 415
svc #0sites: 327 with an adjacent number load, 76 with a number load only within the preceding eight instructions, and 12 with neither. The linker contained 56 sites, 45 adjacent and 11 nearby-only. Nearby counts are scan statistics, not proof that the number remains live at the syscall.
The initial adjacent-only scanner patched nine sites; the final scanner patched
14, including file-creation paths, across libc and the linker. It used two relay
pages in standalone validation processes. bench/svc-check.c exercises the
actual emitted relay and checks raw success/error results, errno, x1–x30, q0–q31,
NZCV, FPCR/FPSR, four simultaneous threads, the multi-thread patching guard, and fork.
Null arguments and unsupported flags fall back to PRoot. The instruction decoder
and exact ±128 MiB branch limits also have a host regression test.
Default-policy validation on the Thor confirmed that an unset PROOT_FP_PATCH_SVC
and explicit 1 both patched 14 sites; explicit 0 left all 415 libc and 56 linker
syscall sites unchanged. The register/thread/fork check also passed with the
environment flag unset.
Five alternating pairs of bench/svc-workload.c runs compared the same replacement
library with patching off/on, with fast-path stats enabled in both. The workload
gets fstatat and __open64_nocancel directly from libc, bypassing the preload's
public wrappers, and makes 5,000 of each call against a prepared regular file:
| median | wrappers only | svc patching |
|---|---|---|
internal fstatat |
38.6 µs | 3.0 µs |
| internal open + close | 39.3 µs | 5.8 µs |
| voluntary context switches over both loops | 10,000 | 0 |
SD allocation and writes
bench/svc-storage.c separates the operations relevant to Steam downloads. Three
alternating pairs ran on internal /tmp and the selected /mnt/droiddeck-sd library,
with Steam idle and no simultaneous local Steam download. Each fresh directory held 64 long-named files
extended to 256 KiB, followed by 1,000 libc-internal stats and a 64 MiB sequential
write followed by fdatasync. Both modes used the same replacement library; only
PROOT_FP_PATCH_SVC changed. Medians on SD were:
| phase | wrappers only | svc patching |
|---|---|---|
| create/extend/close 64 files | 1.500 s | 1.486 s |
| 1,000 internal stats | 61.2 ms | 5.4 ms |
| 64 MiB write calls | 43.9 ms | 40.9 ms |
final fdatasync |
2.514 s | 2.539 s |
Internal allocation fell from 7.1 ms to 1.9 ms; internal stats fell from 39.5 ms
to 2.5 ms. Internal fdatasync varied from 36 to 164 ms across individual runs.
The patched SD workload recorded 1,065 internal calls handled in-process, covering
64 file creations, 1,000 stats and the streaming-file creation. Per-phase medians
are independent and should not be added as a median whole-run duration.
The SD metadata gain does not establish faster Steam downloads. File creation improved by only a few percent, and write/flush speed did not improve in this probe. The remaining SD cost is much larger than the saved syscall-interception time. The earlier Thor SD investigation likewise identified native exFAT metadata waits; the current benchmark does not independently attribute those waits to a particular mount flag. A real Steam install comparison must separate preallocation, transfer/staging and final commit before claiming an end-to-end improvement.
Real Steam downloads to SD
Four fresh installs of owned, initially uninstalled 7 Billion Humans (792100)
used library folder 1, /mnt/droiddeck-sd, and were uninstalled between trials.
Each downloaded 228,935,584 bytes and staged 237,717,350 bytes in 19 files, ending
at BuildID 8016557, depots 792101/5052600457178655633 and
792104/8245917369753676198. Every trial logged zero reused/stored/delta bytes at
the start and completed with No Error. Existing installed games were retained.
The repeat off/on pair used the exact same replacement library, SHA-256
ab605c021aee271cd49fa1cb337d7ff0c762dbac198b711e9818639c0cd4b17d.
The first off run used the earlier adjacent-only build, with patching disabled.
Only the patching environment flag changed for the final pair. Times below come
from Steam's one-second-resolution content_log.txt; total starts at Reconfiguring
and ends at scheduler completion, excluding session startup:
| trial | patching | preallocate | download/stage | commit to completion | total |
|---|---|---|---|---|---|
| first baseline | off | 1 s | 123 s | 1 s | 135 s |
| first patched | on | <1 s | 47 s | 1 s | 50 s |
| repeat baseline | off | <1 s | 30 s | 1 s | 33 s |
| repeat patched | on | <1 s | 29 s | 2 s | 32 s |
Reconfiguration accounts for the remaining total time. The first baseline
overlapped another Steam client's network download; that client's telemetry must
not be mistaken for the Thor's. Later samples were filtered to local client 0.
The repeat pair reached similar network rates, roughly 7–8 MB/s while transferring.
All four attempts logged the same unsupported large-file preallocation retry.
This does not demonstrate a useful SD-download speedup. The apparent initial 135→50 second gain disappeared when baseline was repeated. The final 33 versus 32 seconds is within this small experiment's timing/network variability. This 19-file title also does not settle performance for games with thousands of loose files. Metadata calls clearly improve, while SD creation/writeback remain the stronger next targets. No network settings, SD mount flags or root privileges were changed for these tests.
The internal-call equivalence workload, including fts, realpath, reentrant NSS,
runtime dlopen, missing paths and fallback errors, matched PROOT_FP_OFF=1 at both
the default parent-cache TTL and TTL 0. The existing equiv.py also matched after
normalizing random/thermal read payloads to their lengths, volatile PID values in
the /proc directory listing, and tolerating Android's
denied /dev directory read. Its root fixtures were relocated into a temporary tree.
equiv2.py used temporary root/SD fixtures, normalized /proc/<self-pid> output,
and tolerated the SD filesystem's unsupported symlink creation. Its patched output
matched the wrapper-only output at both TTLs. It did not fully match PRoot:
binding a Unix socket in the selected SD bind returned EPERM with the existing
wrappers, whereas PRoot succeeded. This mismatch reproduced with svc patching off;
it is a separate existing fast-path socket issue. The suite is not a clean full
equivalence pass. equiv3.py was not run because it rewrites live NSS configuration.
A fresh Steam session using the override and PROOT_FP_PATCH_SVC=1 rendered its
library UI. Sonic Mania (584400) gained focus and rendered its title screen. This
is startup smoke coverage, not gameplay or a Steam startup-speed measurement.
The installed PRoot pack emitted no profiling start/counter records despite the
queued profiling setting; the profiler report had hasCounters: false. That is a
measurement gap, not evidence of zero remaining traps.
Expanded relay coverage (before the safety follow-up)
The subsequent revision keeps patching enabled by default and shares the existing wrapper translators with 20 path syscall numbers: openat, newfstatat, statx, readlinkat, faccessat, mkdirat, unlinkat, renameat, renameat2, symlinkat, fchmodat, utimensat, truncate, statfs, getxattr, lgetxattr, listxattr, llistxattr, inotify_add_watch and getcwd. This is an allowlist of safely recognized sites, not a claim that every glibc implementation contains every site. Unsupported paths, flags, instructions and inaccessible pointers still use the original PRoot route. Input/output copies use process_vm_readv/writev rather than dereferencing arbitrary guest pointers. The device matrix checks error priority, protected buffers and a NUL-terminated path ending at a page boundary.
The app forwards its Android SDK as PROOT_FP_ANDROID_API. With a known SDK,
set_robust_list retains the head in TLS and returns ENOSYS, matching patch 0016;
self get_robust_list uses that head. Uncaptured heads and other TIDs fall back to
PRoot. This does not implement kernel robust-mutex owner-death recovery. faccessat2
returns ENOSYS for glibc's fallback. clone3 returns ENOSYS only before API 35;
it is never executed from the C relay, since successful clone3 would change the
child's stack. The Android 14 allowlist
omits clone3 and the Android 15 allowlist
includes it. Unknown platforms leave these sites untouched. Existing droiddeck-fsync
handling of futex_waitv is unchanged.
Build-ID tables in /tmp/.droiddeck-svc-v1-<api>-<build-id> cache relative site
offsets. Every hit revalidates executable-segment bounds, alignment, the current
SVC opcode and the recognized syscall number. Missing, malformed, truncated,
overly permissive or symlinked tables trigger a rescan; writes use a private
temporary file and atomic rename. Images without a readable GNU build-ID still
scan normally. PROOT_FP_SVC_CACHE=0 disables caching for comparison. Changes to
the accepted instruction/syscall set must bump the cache version.
On the Thor's glibc 2.43 / Android 13, 49 sites patch across libc and the loader. Warm cached discovery took 60–63 microseconds in two samples versus roughly 0.6–0.7 milliseconds for the earlier uncached scan; patch publication took about 0.28 milliseconds. These are constructor phases, not whole Steam startup times. Five alternating expanded-library pairs measured median internal stat at 39.5→4.9 microseconds and open/close at 41.2→6.4 microseconds, with 10,000→0 voluntary context switches over both loops. The earlier SD/download tables above describe the initial 14-site revision and must not be attributed to this expanded build.
Five alternating pairs of svc-threads additionally measured thread create/join
at 138→113 microseconds, libc faccessat at 51.6→14.7 microseconds, and libc getcwd
at 46.8→4.1 microseconds. Each run creates 128 threads and executes 2,000 calls
to each libc entry point, bypassing the exported preload wrappers with dlsym.
The access call includes glibc's faccessat2 fallback.
Repeated /usr/bin/true launches expose the startup tradeoff. Five batches of
40 children per mode measured medians of 3.834 ms with wrappers only, 4.882 ms
with uncached patching and 4.731 ms with cached patching, including subprocess
launch/wait and enabled exit statistics. Discovery across the 200 launches per
patched mode fell from a median 496 to 59 microseconds; publishing patches took
261/267 microseconds. The cache removes scanning work, but constructor setup and
patching still cost time for very short programs. No overall exec speedup is claimed.
Three alternating storage pairs with the expanded library measured:
| SD phase | wrappers only | expanded relays |
|---|---|---|
| create/extend/close 64 files | 1.556 s | 1.514 s |
| 1,000 internal stats | 73.2 ms | 12.5 ms |
| 64 MiB write calls | 54.2 ms | 55.1 ms |
| final fdatasync | 2.799 s | 2.730 s |
These phase medians do not establish a general write-speed improvement. Internal allocation was 7.1→3.6 ms and internal stats 49.8→7.7 ms; the SD filesystem's allocation and flush waits remain much larger than the interception savings.
Expanded-build Steam SD comparison
Owned, initially uninstalled Half-Life (70) supplies a larger file tree: 4,525
installed files / 589,603,955 bytes, BuildID 15961492. Each fresh trial initially
scheduled 294,238,640 download bytes, 589,603,955 staged bytes and 4,488 preallocated
files on /mnt/droiddeck-sd. Steam reported successful installation in every run.
Local client 0 telemetry was selected. Times use one-second content-log timestamps;
total runs from the first main-app Reconfiguring event to scheduler completion.
| trial | patching | initial preallocate | transfer/stage window | final commit | total |
|---|---|---|---|---|---|
| first baseline | off | 90 s | 52 s | 204 s | 350 s |
| expanded, default setting | on | 94 s | 48 s | 184 s | 326 s |
| repeat baseline | off | 97 s | 69 s | 175 s | 341 s |
The patched and repeat-baseline trials use the same final library SHA-256
af4f66680dcfbd5002f641ffe206cdc7bd89400be169d3ef21c4e0ee7f979589.
The first baseline used the preceding build with patching disabled; the only
subsequent production change corrected explicitly cleared robust-list TLS state.
All initial download records showed zero stored/reused/delta bytes. Shader updates
interrupted the main download in each trial; the transfer window includes those
pauses. On the repeat baseline, reconfiguration retained two small files already
downloaded during that same trial, so the final resumed job scheduled 294,236,672
download / 589,599,865 stage bytes and committed 4,572 entries rather than 4,574.
This is not pre-existing payload reuse; the earlier fresh schedule is retained
in the measurement records.
The observed whole-install difference does not establish a reliable SD speedup: the repeat baseline committed faster than the patched run, while its transfer window was slower. Allocation also did not improve. The 326 versus 341–350 second totals combine network, shader scheduling and SD variability. The syscall gains are demonstrated independently; these samples do not show that they fix Steam's SD allocation/commit bottleneck. The test title was removed between trials; an interrupted first uninstall left a repair queue, which was cleared by removing only that task-created title while Steam was stopped before the subsequent fresh trial. Existing installed games were retained.
The expanded ABI matrix passes raw return values, errno, all x1–x30 and q0–q31, NZCV and non-default FPCR/FPSR, four concurrent threads with distinct robust heads, fork, 100 signals on an alternate stack, deferred cancellation, cancellation/cleanup of a blocked libc FIFO open, clearing/restoring a robust head, and 32 thread create/join cycles. Constructor patching still refuses a process with multiple live threads. Host tests exercise corrupted cache entries, invalid offsets/opcodes, mismatched API/version, unsafe file modes, symlinks and truncation. The Android unit test checks API forwarding. The existing preload/agent selection passed 79 tests with two skips before the cache regression test was added. API-selection probes patch 45 sites with an unknown SDK, 49 with API 33 and 48 with simulated API 35; unset and explicit 1 agree, and explicit 0 leaves the original sites intact. This does not substitute for testing on Android 15 hardware.
CI at 40b7c480 passed 1,427 Python/native tests (49 skips), 2,630 Android tests and
162 Rust host tests, plus APK compilation. A local release APK built successfully,
passed runtime-payload/native-library checks and was signed with the installed
release certificate. The full local Windows JVM run reported 150 failures / 2,568
tests (eight skips), including Unix-path, symlink and permission assumptions;
the four ProotFastPath tests passed in both local variants. The passing full
suite evidence is the Linux CI run, not a clean Windows full-suite result.
Internal fts/realpath/NSS/dlopen and the normalized first equivalence suite still match PRoot at both cache TTLs. The write suite still matches wrapper-only output and reproduces the same existing SD Unix-socket mismatch described above. No in-process RET_TRAP filter is installed, so the proposed SIGSYS mask/handler machinery is not applicable. Startup loader traps before the constructor and raw calls in arbitrary guest ELFs remain outside the patch scope. Additional glibc builds, Android versions, unwind behavior, Steam/CEF tree RSS and matching patch-0018 tracer measurements remain coverage gaps.
Build the tools with the same ARM64 glibc toolchain as the fast path:
aarch64-linux-gnu-gcc -O2 -Wall -Wextra -pthread \
-o svc-check tools/proot/bench/svc-check.c -ldl
aarch64-linux-gnu-gcc -O2 -Wall -Wextra \
-o svc-probe tools/proot/bench/svc-probe.c -ldl
aarch64-linux-gnu-gcc -O2 -Wall -Wextra \
-o svc-workload tools/proot/bench/svc-workload.c -ldl
aarch64-linux-gnu-gcc -O2 -Wall -Wextra \
-o svc-storage tools/proot/bench/svc-storage.c -ldl
aarch64-linux-gnu-gcc -O2 -Wall -Wextra -pthread \
-o svc-threads tools/proot/bench/svc-threads.c -ldl
Run svc-probe inside the app's guest environment, not from adb shell.
svc-check embeds the fast path and requires its normal enabled tracer environment.
For that embedded-library check, start with PROOT_FP_PATCH_SVC=0 when the session
also preloads a fast-path library (including through /etc/ld.so.preload). The check
then enables its own copy at main before creating threads, keeping another copy
from owning the glibc patch sites. The other tools use the normal preload.
For svc-workload, pass a prepared directory containing file and a link symlink
to it; add any second argument to enable timing. Use the documented library override
workflow in proot-performance for a fresh session. No extra
environment setting is needed; use PROOT_FP_PATCH_SVC=0 for the unpatched comparison.
svc-storage takes a new, unused directory name; it creates and removes its own test
files and directory. It writes 64 MiB plus 16 MiB of file extensions per run.
Safety follow-up (2026-10-28)
Path patching remains enabled by default. Robust-list registration/query sites are now deliberately excluded: a device regression test reproduced disagreement between the relay's private TLS head and an unpatched raw get_robust_list call in the preceding implementation. Both directions now use the existing PRoot/kernel route, including cleared heads. This preserves the host's actual return policy: the installed Thor runtime accepts set_robust_list with zero, whereas the removed emulation returned ENOSYS. PRoot patch 0016 can instead return ENOSYS when Android blocks registration; neither policy is replaced by private relay state.
The build-ID cache is version 2 because the accepted syscall set changed. The current glibc 2.43/API-33 build patches 47 sites rather than 49. faccessat2 and pre-Android-15 clone3 fallback acceleration remain; droiddeck-fsync is unchanged. The earlier 138-to-113-microsecond thread result belongs to the retired robust-list implementation and must not be claimed for the current code.
Relays now save the original post-SVC program counter and supply unwind metadata for the saved caller registers. Device tests force an unwind from inside the translator through a patched libc frame and verify caller cleanup. A signal handler also exits with siglongjmp; a later call recovers the abandoned recursion guard and resumes acceleration. These tests supplement deferred and blocked-FIFO cancellation; they are not a claim of arbitrary asynchronous-cancellation safety.
Compiler stack-usage output identified multiple nested PATH_MAX-sized frames. Before entering them, a small dispatcher checks the active alternate signal stack and cached writable stack mapping. Alternate-stack calls and calls with less than 96 KiB remaining use PRoot. Main-stack growth respects the current RLIMIT_STACK and leaves space above the preceding mapping. The implementation adds only a few TLS words, not a large private translation stack. Tests cover a restricted thread stack, normal/alternate-stack signals, main-stack growth and a reduced/unavailable stack limit. User-managed stacks and arbitrary mid-helper non-local exits remain broader compatibility risks, not proven consequence-free cases.
On Thor/Android 13, the embedded and shared-PIC matrices pass on glibc 2.43; the shared-PIC matrix also passes with a separately loaded glibc 2.39. They cover register/FP/errno preservation, mixed raw and relay robust calls, pointer and page boundaries, readlink buffers of target length minus one through plus two, thread churn, fork, signal/non-local exits, forced unwinding and limited stack space. Readlink preserves PRoot's existing translated-link length behavior; bytes beyond the reported payload are unspecified.
Test-process seccomp filters separately deny opening /proc/self/mem, nearby relay mappings, executable mprotect and patch writes. All four device cases leave zero patched sites and a working original libc syscall. Host injection additionally checks that one failed site write permits other sites to install. Concurrent atomic symlink replacement passes 3,000 internal stat/readlink iterations at both zero and default cache TTLs. These are bounded tests; they do not prove every possible filesystem race or reproduce a real SELinux execmod denial.
Five new alternating pairs measured:
| operation | wrappers only | hardened relays |
|---|---|---|
| libc-internal stat | 50.1 us | 5.9 us |
| libc-internal open/close | 51.9 us | 7.1 us |
| libc faccessat | 52.0 us | 20.2 us |
| libc getcwd | 51.7 us | 4.6 us |
| thread create/join | 129 us | 144 us |
| complete tiny exec/wait | 5.04 ms | 5.96 ms |
The metadata gains survive the safety checks. This run shows no thread-creation gain, and the roughly 0.9 ms tiny-exec cost remains. The earlier SD download and flush results are historical measurements of the preceding build; no new claim that SD media writes or whole Steam installs become reliably faster follows from these syscall results. Android 15+ ARM64 and 16 KiB-page hardware remain untested.
Three alternating pairs also measured session start through the Steam Big Picture Home page becoming ready, using its DOM/Recent Games content rather than merely the compositor's first frame. Off runs took 48.76/43.47/47.02 seconds; on runs took 43.43/45.25/50.40 seconds. The 47.02-to-45.25-second medians sit within overlapping run-to-run variation and do not establish a reliable whole-client startup win. These use the signed bundled-library build without overrides; the final stack-gap guard only increases the reserved distance above other mappings.
At Home-ready plus 20 seconds, three one-second-spaced process-memory samples per run gave Steam/CEF-only median PSS of 1,223.1 MiB off and 1,233.9 MiB on. Including their active Python/Wine descendants gave 1,243.7/1,254.6 MiB. The five Steam/CEF processes were selected by executable name; descendants were included by parent PID. Per-run Steam/CEF medians ranged 1,217.9–1,240.9 MiB off and 1,194.8–1,244.9 MiB on. The roughly 11 MiB median increase is smaller than this variation, but it is not evidence of zero memory cost. PSS avoids counting shared pages in full for each process. Startup and memory comparisons used one unchanged APK; no downloads or games were initiated during them.
CodeRabbit follow-up: the workload now compares canonical paths, so relative and absolute fixture arguments agree. All SVC bench tools keep their checks active under NDEBUG; their clock, syscall and thread operations cannot silently disappear. Both cases were rebuilt with -DNDEBUG and checked on Thor. The memory-file-denial test now blocks only O_RDWR opens and asserts the single-thread check still works, so it reaches the intended /proc/self/mem failure. The initial proroot comparison also distinguishes its limited in-process fallback from a general tracer. The request to return to default-off was addressed through the interruption/unwind fixes and tests while retaining the requested default. The proposed readlink count change was not applied because exact boundary tests reproduce the current PRoot behavior; changing that inherited policy belongs in a separate correctness change.