Files
DroidDeck/tools/tests/test_openat2_preload.py
T
MaxsTechReview 3305724f06 perf: FEX's file lookups and opens go through the fast path instead of proot (#1283)
* perf: FEX's in-root path lookups go through the fast path instead of proot

Android has no openat2, so libblsession walks RESOLVE_IN_ROOT lookups for
FEX one component at a time. Every fstatat, readlinkat and openat in that
walk was a raw syscall that stopped in proot. When libblfastpath is loaded,
the walk now calls its fstatat, readlinkat and openat, which answer without
proot and fall back to the raw syscall themselves. Without it the walk uses
the raw syscalls as before.

One lookup drops from 250-940 us to 26-86 us. A Graveyard Keeper launch
makes 37.5k proot stops instead of 54.0k and reaches its first scene
0.31 s sooner.

* test: count fstatat64 where the preload asks for it

* perf: FEX's own file opens go through the fast path instead of proot

FEX opens host files for the game with syscall(SYS_openat), so every one
stopped in proot even when the fast path could answer it. libblsession's
syscall() now sends SYS_openat to the fast path's openat as well, the same
way the in-root walk does, and makes the raw syscall when the fast path is
not loaded.

A Graveyard Keeper launch makes 28.0k proot stops instead of 37.5k; FEX's
openat stops go from 6,739 to none.

* fix: the fast path's start-up reads don't come back through syscall()

libblsession's syscall() now sends SYS_openat to the fast path. A process
named FEX starts the fast path by reading /proc/self/cmdline with
syscall(SYS_openat), which came straight back into the fast path and
waited on its own pthread_once. Its start-up reads now call libc's
syscall directly.
2026-10-21 22:33:39 -04:00

319 lines
14 KiB
Python

import errno
import os
from pathlib import Path
import shutil
import subprocess
import tempfile
import unittest
PRELOAD = Path(__file__).resolve().parents[1] / "linuxfs/preload"
FASTPATH = Path(__file__).resolve().parents[1] / "proot/fastpath/fastpath.c"
# Opens each "<dirfd>\t<flags>\t<path>" case with openat2(RESOLVE_IN_ROOT) as FEX does: first through the kernel's own
# openat2 on argv[1] when it has one, then, with openat2 failing ENOSYS as under proot, through the preload on argv[2].
PROGRAM = r"""
#define _GNU_SOURCE
#include <dlfcn.h>
#include <errno.h>
#include <fcntl.h>
#include <linux/filter.h>
#include <linux/seccomp.h>
#include <stddef.h>
#include <stdint.h>
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <sys/prctl.h>
#include <sys/stat.h>
#include <sys/syscall.h>
#include <unistd.h>
#ifndef SYS_openat2
#define SYS_openat2 437
#endif
struct how { uint64_t flags, mode, resolve; };
static char cases[256][512];
static int count;
static void run(long (*call)(long, ...), const char *tree) {
int root = open(tree, O_PATH | O_DIRECTORY | O_CLOEXEC), outer = open("/", O_PATH | O_DIRECTORY | O_CLOEXEC);
for (int i = 0; i < count; i++) {
char *flags = strchr(cases[i], '\t') + 1, *path = strchr(flags, '\t') + 1;
struct how h = {strtoull(flags, NULL, 0), 0, 0x10};
if (h.flags & O_CREAT) h.mode = 0644;
long fd = call(SYS_openat2, (long)(cases[i][0] == 'o' ? outer : root), (long)path, (long)&h, (long)sizeof(h));
struct stat st;
if (fd < 0) {
printf("ERR %d\n", errno);
} else {
fstat((int)fd, &st);
printf("OK %llu %llu\n", (unsigned long long)st.st_dev, (unsigned long long)st.st_ino);
close((int)fd);
}
}
}
int main(int argc, char **argv) {
if (argc < 3) return 2;
while (count < 256 && fgets(cases[count], sizeof(cases[count]), stdin)) {
cases[count][strcspn(cases[count], "\n")] = 0;
count++;
}
long (*kernel)(long, ...) = (long (*)(long, ...))dlsym(dlopen("libc.so.6", RTLD_LAZY | RTLD_NOLOAD), "syscall");
struct how probe = {O_RDONLY, 0, 0x10};
long fd = kernel(SYS_openat2, (long)AT_FDCWD, (long)".", (long)&probe, (long)sizeof(probe));
if (fd >= 0) {
close((int)fd);
printf("kernel\n");
run(kernel, argv[1]);
struct sock_filter filter[] = {
BPF_STMT(BPF_LD | BPF_W | BPF_ABS, offsetof(struct seccomp_data, nr)),
BPF_JUMP(BPF_JMP | BPF_JEQ | BPF_K, SYS_openat2, 0, 1),
BPF_STMT(BPF_RET | BPF_K, SECCOMP_RET_ERRNO | ENOSYS),
BPF_STMT(BPF_RET | BPF_K, SECCOMP_RET_ALLOW),
};
struct sock_fprog prog = {sizeof(filter) / sizeof(filter[0]), filter};
if (prctl(PR_SET_NO_NEW_PRIVS, 1, 0, 0, 0) || prctl(PR_SET_SECCOMP, SECCOMP_MODE_FILTER, &prog)) {
perror("seccomp");
return 3;
}
}
printf("preload\n");
run(syscall, argv[2]);
return 0;
}
"""
COUNTER = r"""
#define _GNU_SOURCE
#include <dlfcn.h>
#include <fcntl.h>
#include <stdarg.h>
#include <stdio.h>
#include <sys/stat.h>
#include <unistd.h>
static int opens, stats, links;
int openat(int dirfd, const char *path, int flags, ...) {
va_list args;
va_start(args, flags);
mode_t mode = va_arg(args, int);
va_end(args);
opens++;
return ((int (*)(int, const char *, int, ...))dlsym(RTLD_NEXT, "openat"))(dirfd, path, flags, mode);
}
int fstatat(int dirfd, const char *path, struct stat *st, int flags) {
stats++;
return ((int (*)(int, const char *, struct stat *, int))dlsym(RTLD_NEXT, "fstatat"))(dirfd, path, st, flags);
}
int fstatat64(int dirfd, const char *path, struct stat64 *st, int flags) {
stats++;
return ((int (*)(int, const char *, struct stat64 *, int))dlsym(RTLD_NEXT, "fstatat64"))(dirfd, path, st, flags);
}
ssize_t readlinkat(int dirfd, const char *path, char *buf, size_t size) {
links++;
return ((ssize_t (*)(int, const char *, char *, size_t))dlsym(RTLD_NEXT, "readlinkat"))(dirfd, path, buf, size);
}
__attribute__((destructor)) static void report(void) {
fprintf(stderr, "calls %d %d %d\n", opens, stats, links);
}
"""
RAW_OPEN = r"""
#define _GNU_SOURCE
#include <fcntl.h>
#include <stdio.h>
#include <sys/syscall.h>
#include <unistd.h>
int main(int argc, char **argv) {
char text[16] = "";
long fd = syscall(SYS_openat, AT_FDCWD, argv[1], O_RDONLY | O_CLOEXEC, 0);
if (fd < 0 || read((int)fd, text, sizeof(text) - 1) < 0) return 1;
printf("%s\n", text);
return syscall(SYS_openat, AT_FDCWD, argv[2], O_RDONLY | O_CLOEXEC, 0) == -1 ? 0 : 1;
}
"""
O_PATH = getattr(os, "O_PATH", 0o10000000)
# (dirfd: r = the tree, o = "/"), flags, path, expected: a path in the tree, an errno name, or "proc" for a file outside it.
CASES = [
("r", os.O_RDONLY, "etc/alternatives/hn", "etc/hostname"),
("r", os.O_RDONLY, "/lib64/x86_64-linux-gnu/libfoo.so.1", "usr/lib/x86_64-linux-gnu/libfoo.so.1.2"),
("r", os.O_RDONLY, "lib/x86_64-linux-gnu/libfoo.so.1", "usr/lib/x86_64-linux-gnu/libfoo.so.1.2"),
("r", os.O_RDONLY, "etc/escape", "etc/hostname"),
("r", os.O_RDONLY, "dirlink/../etc/hostname", "etc/hostname"),
("r", os.O_RDONLY, "rootlink/rootlink//etc/./hostname", "etc/hostname"),
("r", os.O_RDONLY, "../../etc/hostname", "etc/hostname"),
("r", os.O_RDONLY | os.O_DIRECTORY, "/", "."),
("r", os.O_RDONLY | os.O_DIRECTORY, "usr/../..", "."),
("r", os.O_RDONLY, "chain/l2", "chain/end"),
("r", os.O_RDONLY, "chain/l1", "ELOOP"),
("r", os.O_RDONLY, "loop/a/x", "ELOOP"),
("r", os.O_RDONLY | os.O_NOFOLLOW, "etc/alternatives/hn", "ELOOP"),
("r", O_PATH | os.O_NOFOLLOW, "etc/alternatives/hn", "etc/alternatives/hn"),
("r", os.O_RDONLY | os.O_NOFOLLOW, "lib/x86_64-linux-gnu/libfoo.so.1.2", "usr/lib/x86_64-linux-gnu/libfoo.so.1.2"),
("r", os.O_RDONLY | os.O_NOFOLLOW | os.O_DIRECTORY, "dirlink/", "usr"),
("r", os.O_RDONLY, "file/", "ENOTDIR"),
("r", os.O_RDONLY, "file/../etc/hostname", "ENOTDIR"),
("r", os.O_RDONLY, "nonexist/../etc/hostname", "ENOENT"),
("r", os.O_RDWR | os.O_CREAT | os.O_EXCL, "dangling", "EEXIST"),
("r", os.O_RDWR | os.O_CREAT, "dangling", "usr/newfile"),
("r", os.O_RDWR | os.O_CREAT, "missing/newfile", "ENOENT"),
("r", os.O_RDWR | os.O_CREAT, "newdir/", "EISDIR"),
("o", os.O_RDONLY | os.O_NONBLOCK, "proc/self/fd/0", "EXDEV"),
("o", os.O_RDONLY, "proc/self/exe", "EXDEV"),
("o", os.O_RDONLY, "proc/self/cwd/x", "EXDEV"),
("o", os.O_RDONLY, "proc/self/status", "proc"),
]
# Only the preload: an absolute link to a path missing in the tree but present outside it, as droiddeck-fex's rootfs has.
HOST_CASES = [
("r", os.O_RDONLY, "lib/x86_64-linux-gnu/libbar.so.2", "host:libbar.so.2"),
("r", os.O_RDONLY, "hostdir/sub/data", "host:sub/data"),
("r", os.O_RDONLY | os.O_DIRECTORY, "hostdir/sub", "host:sub"),
("r", os.O_RDONLY, "etc/alternatives/hn", "etc/hostname"),
("r", os.O_RDONLY, "hostdir/missing", "ENOENT"),
]
def make_tree(root, outside):
for folder in ("usr/lib/x86_64-linux-gnu", "etc/alternatives", "loop", "chain"):
(root / folder).mkdir(parents=True)
(root / "usr/lib/x86_64-linux-gnu/libfoo.so.1.2").write_text("x86")
(root / "etc/hostname").write_text("inside")
(root / "file").write_text("f")
(root / "chain/end").write_text("end")
links = {"usr/lib/x86_64-linux-gnu/libfoo.so.1": "libfoo.so.1.2", "lib": "usr/lib", "lib64": "/usr/lib",
"etc/alternatives/hn": "/etc/hostname", "etc/escape": "../../../../../../etc/hostname", "loop/a": "b",
"loop/b": "a", "dirlink": "/usr", "rootlink": "/", "dangling": "/usr/newfile"}
links.update({"chain/l%d" % i: ("l%d" % (i + 1) if i < 41 else "end") for i in range(1, 42)})
links.update({"usr/lib/x86_64-linux-gnu/libbar.so.2": str(outside / "libbar.so.2"), "hostdir": str(outside)})
for link, target in links.items():
os.symlink(target, root / link)
def make_outside(outside):
(outside / "sub").mkdir(parents=True)
(outside / "libbar.so.2").write_text("host")
(outside / "sub/data").write_text("host")
def labels(lines, root, outside):
inodes = {(os.lstat(root).st_dev, os.lstat(root).st_ino): "."}
for base, prefix in ((root, ""), (outside, "host:")):
for folder, dirs, files in os.walk(base):
for name in dirs + files:
st = os.lstat(os.path.join(folder, name))
inodes[(st.st_dev, st.st_ino)] = prefix + os.path.relpath(os.path.join(folder, name), base)
out = []
for line in lines:
kind, *values = line.split()
if kind == "OK":
out.append(inodes.get((int(values[0]), int(values[1])), "proc"))
else:
out.append(errno.errorcode[int(values[0])])
return out
@unittest.skipUnless(shutil.which("cc") and os.path.isdir("/proc/self"), "needs cc and procfs")
class Openat2PreloadTest(unittest.TestCase):
@classmethod
def setUpClass(cls):
cls.tmp = Path(tempfile.mkdtemp())
cls.lib = cls.tmp / "libopenat2.so"
subprocess.run(["cc", "-O2", "-Wall", "-Werror", "-fPIC", "-shared", "-pthread", "-o", str(cls.lib),
str(PRELOAD / "robust.c"), str(PRELOAD / "fsync.c"), str(PRELOAD / "noexec.c"), "-ldl"], check=True)
source = cls.tmp / "openat2.c"
source.write_text(PROGRAM)
subprocess.run(["cc", "-O2", "-Wall", "-Werror", "-o", str(cls.tmp / "openat2"), str(source), "-ldl"], check=True)
cls.trees = (cls.tmp / "kernel", cls.tmp / "preload")
cls.outside = cls.tmp / "outside"
make_outside(cls.outside)
for tree in cls.trees:
make_tree(tree, cls.outside)
every = CASES + HOST_CASES
cases = "".join("%s\t%d\t%s\n" % case[:3] for case in every)
result = subprocess.run([str(cls.tmp / "openat2"), *map(str, cls.trees)], input=cases, capture_output=True, text=True,
env=dict(os.environ, LD_PRELOAD=str(cls.lib)), timeout=60)
if result.returncode != 0:
raise AssertionError(result.stdout + result.stderr)
lines = result.stdout.splitlines()
cls.kernel = labels(lines[1:1 + len(CASES)], cls.trees[0], cls.outside) if lines[0] == "kernel" else None
cls.preload = labels(lines[-len(every):], cls.trees[1], cls.outside)
counter = cls.tmp / "counter.c"
counter.write_text(COUNTER)
(cls.tmp / "fp").mkdir()
subprocess.run(["cc", "-O2", "-Wall", "-Werror", "-fPIC", "-shared", "-o", str(cls.tmp / "fp/libblfastpath.so"),
str(counter), "-ldl"], check=True)
tree = cls.tmp / "counted"
make_tree(tree, cls.outside)
counted = subprocess.run([str(cls.tmp / "openat2"), str(tree), str(tree)], input=cases, capture_output=True,
text=True, env=dict(os.environ, LD_PRELOAD="%s:%s" % (cls.lib, cls.tmp / "fp/libblfastpath.so")),
timeout=60)
if counted.returncode != 0:
raise AssertionError(counted.stdout + counted.stderr)
cls.counted = labels(counted.stdout.splitlines()[-len(every):], tree, cls.outside)
cls.calls = [int(n) for n in counted.stderr.split("calls ")[-1].split()]
raw = cls.tmp / "raw_open.c"
raw.write_text(RAW_OPEN)
subprocess.run(["cc", "-O2", "-Wall", "-Werror", "-o", str(cls.tmp / "raw_open"), str(raw)], check=True)
cls.raw = subprocess.run([str(cls.tmp / "raw_open"), str(tree / "etc/hostname"), str(tree / "missing")],
capture_output=True, text=True, timeout=60,
env=dict(os.environ, LD_PRELOAD="%s:%s" % (cls.lib, cls.tmp / "fp/libblfastpath.so")))
(cls.tmp / "real").mkdir()
subprocess.run(["cc", "-O2", "-Wall", "-Werror", "-fPIC", "-shared", "-pthread", "-o",
str(cls.tmp / "real/libblfastpath.so"), str(FASTPATH), "-ldl"], check=True)
shutil.copy(cls.tmp / "raw_open", cls.tmp / "FEX")
try:
cls.fex = subprocess.run([str(cls.tmp / "FEX"), str(tree / "etc/hostname"), str(tree / "missing")],
capture_output=True, text=True, timeout=20,
env=dict(os.environ, PROOT_FP_ROOT="/", PROOT_FP_KEY="test",
LD_PRELOAD="%s:%s" % (cls.lib, cls.tmp / "real/libblfastpath.so")))
except subprocess.TimeoutExpired:
cls.fex = None
@classmethod
def tearDownClass(cls):
shutil.rmtree(cls.tmp, ignore_errors=True)
def test_preload_resolves_in_the_root_without_openat2(self):
for case, got in zip(CASES, self.preload):
self.assertEqual(got, case[3], case)
self.assertTrue((self.trees[1] / "usr/newfile").exists())
def test_preload_finishes_a_link_to_a_path_only_outside_the_root_there(self):
for case, got in zip(HOST_CASES, self.preload[len(CASES):]):
self.assertEqual(got, case[3], case)
def test_the_walk_asks_the_fast_path_when_it_is_loaded(self):
self.assertEqual(self.counted, self.preload)
self.assertTrue(all(n > 0 for n in self.calls), self.calls)
def test_a_raw_openat_syscall_asks_the_fast_path_too(self):
self.assertEqual(self.raw.returncode, 0, self.raw.stderr)
self.assertEqual(self.raw.stdout, "inside\n")
self.assertEqual(self.raw.stderr.split("calls ")[-1].split(), ["2", "0", "0"])
def test_a_first_raw_openat_in_fex_does_not_wait_on_the_fast_path_starting(self):
self.assertIsNotNone(self.fex, "hung")
self.assertEqual(self.fex.returncode, 0, self.fex.stderr)
self.assertEqual(self.fex.stdout, "inside\n")
def test_the_kernel_agrees(self):
if self.kernel is None:
self.skipTest("this kernel or sandbox has no openat2")
for case, got in zip(CASES, self.kernel):
self.assertEqual(got, case[3], case)
if __name__ == "__main__":
unittest.main()