Files
DroidDeck/docs/development/proroot-analysis.md
T

37 KiB
Raw Blame History

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

  1. Patching svc sites in glibc (patch_phdr_callback, called through dl_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_LOAD it finds svc #0 (0xd4000001). It checks the instructions before it for a mov x8, #nr it recognises. One fixed glibc openat call sequence gets special treatment: its bl is redirected to a relay (emit_openat_call_relay_entry).
    • The site is replaced with a b/bl to a relay stub on a page allocated within ±128 MiB (alloc_relay_page). Sites out of range are logged as bl-out-of-range; large-image multi-cover tramp places 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, it pwrites 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.
  2. In-process SIGSYS fallback:
    • A BPF filter allows syscalls whose instruction pointer is inside proroot's own code and returns SECCOMP_RET_TRAP for 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).
  3. Keeping SIGSYS deliverable, the objection in option 4 of proot-performance:
    • The sigprocmask and pthread_sigmask hooks call sigtrap_unblocked_mask, which sigdelsets SIGSYS (and SIGTRAP for some guests) from any SIG_BLOCK/SIG_SETMASK set.
    • rt_sigaction is guarded so the guest cannot replace the handler (proroot_tramp_rt_sigaction, the sigaction_guard trace points).
    • The raw rt_sigprocmask that glibc makes internally, for example pthread_create blocking every signal, goes through a patched svc site. The stub loader and the runtime both compare against syscall 135.
  4. Android-blocked syscalls are answered in-process. The stub loader dispatches set_robust_list, rseq, faccessat2, clone3, futex_waitv and seccomp. The runtime emulates SysV shm (emu_shm*), set*id/get*id under -0, and brk (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

  1. 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 (and ld-linux-aarch64.so.1 for runtime dlopen), rewrite each svc #0 that follows a recognised mov x8, #nr for the path syscalls the fast path already handles. The rewrite is a b to a relay that calls the existing translator.
    • The relay issues the syscall from the 0xffff00000 trampoline 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 (find over 2.7k files, unchanged today), realpath, nss, __open_nocancel, and dlopen after start-up.
    • Library loading at start-up, before the constructor runs, still traps.
  2. Answer the Android-blocked syscalls through those relays.
    • set_robust_list and get_robust_list: keep the head in TLS, as 0016 already does in proot.
    • faccessat2: answer ENOSYS, or translate it when flags are 0, as the wrapper does.
    • clone3: answer ENOSYS before Android 15, so glibc falls back to clone.
    • futex_waitv: droiddeck-fsync already handles this.
    • Each one removes a SIGSYS stop plus a ptrace round trip, about 30–50 µs. set_robust_list alone is one per thread, which matters for Steam/CEF and Wine thread churn.
  3. The /proc/self/mem write as a fallback for patching. If the app domain refuses mprotect(PROT_WRITE|PROT_EXEC) on rootfs (app_data_file) text, i.e. an execmod denial, a write through /proc/self/mem makes the page copy-on-write without an mprotect. 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).
  4. Keep SIGSYS unblocked if in-process SECCOMP_RET_TRAP is ever used. Strip SIGSYS in the sigprocmask/pthread_sigmask/rt_sigprocmask paths, and keep the handler installed while chaining the guest's. This only becomes safe once item 1 covers glibc's internal rt_sigprocmask. It is a prerequisite, not a recommendation to switch.
  5. 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 /tmp by 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 an app_data_file? Does a /proc/self/mem write work under the app's SELinux context and proot's own ptrace attach? /proc/self/mem writes need PTRACE_MODE_ATTACH on self, which is always allowed.
  • How many libc.so.6 path syscall sites fall outside a recognisable mov x8, #nr window? For example, syscall() takes the number in a register, which needs the existing syscall() 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.py plus a find/realpath/nss workload with PROOT_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/mem accepted 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 #0 sites: 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.