diff --git a/packages/bun-usockets/src/eventing/epoll_kqueue.c b/packages/bun-usockets/src/eventing/epoll_kqueue.c index 79371df7fe13..93a470eaa453 100644 --- a/packages/bun-usockets/src/eventing/epoll_kqueue.c +++ b/packages/bun-usockets/src/eventing/epoll_kqueue.c @@ -142,7 +142,7 @@ static int bun_epoll_pwait2(int epfd, struct epoll_event *events, int maxevents, ret = sys_epoll_pwait2(epfd, events, maxevents, timeout, &mask); } while (ret == -EINTR); - if (LIKELY(ret != -ENOSYS && ret != -EPERM && ret != -EOPNOTSUPP && ret != -EACCES)) { + if (LIKELY(ret != -ENOSYS && ret != -EPERM && ret != -EOPNOTSUPP && ret != -EACCES && ret != -EFAULT)) { return ret; } diff --git a/src/analytics/lib.rs b/src/analytics/lib.rs index de62f1a76dcb..b89e37f289d1 100644 --- a/src/analytics/lib.rs +++ b/src/analytics/lib.rs @@ -515,12 +515,22 @@ pub mod generate_header { #[unsafe(no_mangle)] pub(crate) extern "C" fn Bun__isEpollPwait2SupportedOnLinuxKernel() -> i32 { - #[cfg(not(any(target_os = "linux", target_os = "android")))] + // Android's per-app seccomp policy does not whitelist + // epoll_pwait2 (bionic SYSCALLS.TXT only lists epoll_pwait). + // https://github.com/oven-sh/bun/issues/32489 + #[cfg(not(target_os = "linux"))] { 0 } - #[cfg(any(target_os = "linux", target_os = "android"))] + #[cfg(target_os = "linux")] { + if env_var::feature_flag::BUN_FEATURE_FLAG_DISABLE_EPOLL_PWAIT2 + .get() + .unwrap_or(false) + { + return 0; + } + // https://man.archlinux.org/man/epoll_pwait2.2.en#HISTORY let min_epoll_pwait2 = semver::Version { major: 5, diff --git a/src/bun_core/env_var.rs b/src/bun_core/env_var.rs index 142987436a42..034a73bb7d7d 100644 --- a/src/bun_core/env_var.rs +++ b/src/bun_core/env_var.rs @@ -192,6 +192,13 @@ pub mod feature_flag { new_feature_flag!(pub BUN_FEATURE_FLAG_DISABLE_ISOLATION_SOURCE_CACHE, "BUN_FEATURE_FLAG_DISABLE_ISOLATION_SOURCE_CACHE", {}); new_feature_flag!(pub BUN_FEATURE_FLAG_DISABLE_DNS_CACHE, "BUN_FEATURE_FLAG_DISABLE_DNS_CACHE", {}); new_feature_flag!(pub BUN_FEATURE_FLAG_DISABLE_DNS_CACHE_LIBINFO, "BUN_FEATURE_FLAG_DISABLE_DNS_CACHE_LIBINFO", {}); + // Force the event loop to use epoll_pwait(2) instead of epoll_pwait2(2). + // Escape hatch for seccomp policies that block syscall 441 without + // returning a checkable errno (Android app sandbox, some container + // runtimes). epoll_kqueue.c already falls back on ENOSYS/EPERM/EOPNOTSUPP/ + // EACCES/EFAULT when the syscall returns; this covers environments where + // it faults instead. + new_feature_flag!(pub BUN_FEATURE_FLAG_DISABLE_EPOLL_PWAIT2, "BUN_FEATURE_FLAG_DISABLE_EPOLL_PWAIT2", {}); new_feature_flag!(pub BUN_FEATURE_FLAG_DISABLE_INSTALL_INDEX, "BUN_FEATURE_FLAG_DISABLE_INSTALL_INDEX", {}); // Disable streaming tarball extraction in `bun install`. When disabled, // the whole .tgz is buffered in memory before being decompressed and diff --git a/src/platform/linux.rs b/src/platform/linux.rs index f9e519dec332..123d719d0186 100644 --- a/src/platform/linux.rs +++ b/src/platform/linux.rs @@ -3,26 +3,76 @@ //! If an API can be implemented on multiple platforms, //! it does not belong in this namespace. -use core::ffi::c_long; - // LAYERING: `LinuxMemFdAllocator` lives in `bun_runtime::allocators` (it pulls in // `bun_core`/`bun_sys`/`bun_ptr`); `bun_platform` is below `bun_runtime` so cannot // re-export it. A re-export here would have no consumers — `Blob`/`Store` already // path through `crate::allocators::linux_mem_fd_allocator` directly. -/// Re-encode a glibc `syscall(2)` wrapper return into the raw-kernel -/// convention: on error the kernel returns `-errno` in the result -/// register (i.e. a value in `-4095..=-1`), whereas glibc's wrapper translates that to -/// `-1` and stashes the code in thread-local `errno`. The caller (the C -/// `epoll_kqueue.c` loop) decodes errno *from the return value*, so we must put it -/// back in-band. +/// Raw 6-argument Linux syscall. Returns the kernel return value directly +/// (on error, `-errno` in the range `-4095..=-1`). No libc trampoline, no +/// thread-local `errno` read or write. The C caller in `epoll_kqueue.c` +/// decodes errno from the return value (`ret == -EINTR`, `ret != -ENOSYS`), +/// so the in-band encoding is what it expects. Matches the Zig reference +/// (`std.os.linux.syscall6`), which this replaced. +/// +/// # Safety +/// Arguments must be valid for the syscall identified by `nr`. #[inline(always)] -fn encode_raw_errno(rc: c_long) -> isize { - if rc == -1 { - -(bun_core::ffi::errno() as isize) - } else { - rc as isize +unsafe fn raw_syscall6( + nr: usize, + a1: usize, + a2: usize, + a3: usize, + a4: usize, + a5: usize, + a6: usize, +) -> isize { + #[cfg(target_arch = "x86_64")] + { + let ret: isize; + // SAFETY: Linux x86_64 syscall ABI. `syscall` clobbers rcx and r11; + // arg4 goes in r10 (not rcx). Memory clobber because the kernel may + // read/write through the pointer arguments. + unsafe { + core::arch::asm!( + "syscall", + inlateout("rax") nr as isize => ret, + in("rdi") a1, + in("rsi") a2, + in("rdx") a3, + in("r10") a4, + in("r8") a5, + in("r9") a6, + lateout("rcx") _, + lateout("r11") _, + options(nostack), + ); + } + return ret; } + #[cfg(target_arch = "aarch64")] + { + let ret: isize; + // SAFETY: Linux aarch64 syscall ABI. Syscall number in x8, args in + // x0..x5, return in x0. Memory clobber because the kernel may + // read/write through the pointer arguments. + unsafe { + core::arch::asm!( + "svc #0", + in("x8") nr, + inlateout("x0") a1 as isize => ret, + in("x1") a2, + in("x2") a3, + in("x3") a4, + in("x4") a5, + in("x5") a6, + options(nostack), + ); + } + return ret; + } + #[cfg(not(any(target_arch = "x86_64", target_arch = "aarch64")))] + compile_error!("raw_syscall6: unsupported architecture"); } #[unsafe(no_mangle)] @@ -34,9 +84,9 @@ pub(crate) extern "C" fn sys_epoll_pwait2( sigmask: *const libc::sigset_t, ) -> isize { // SAFETY: direct Linux syscall; arguments mirror the kernel ABI for epoll_pwait2(2). - let rc = unsafe { - libc::syscall( - libc::SYS_epoll_pwait2, + unsafe { + raw_syscall6( + libc::SYS_epoll_pwait2 as usize, epfd as isize as usize, events as usize, maxevents as isize as usize, @@ -48,8 +98,5 @@ pub(crate) extern "C" fn sys_epoll_pwait2( // not glibc's 128-byte userspace sigset_t. See epoll_pwait2(2). 8usize, ) - }; - // The C caller (epoll_kqueue.c) checks `ret == -EINTR` / `ret != -ENOSYS` against the - // raw kernel return, so encode errno back in-band. - encode_raw_errno(rc) + } } diff --git a/test/regression/issue/32489.test.ts b/test/regression/issue/32489.test.ts new file mode 100644 index 000000000000..a9a230e44417 --- /dev/null +++ b/test/regression/issue/32489.test.ts @@ -0,0 +1,206 @@ +import { describe, expect, test } from "bun:test"; +import { bunEnv, bunExe, isLinux, tempDirWithFiles } from "harness"; +import { spawnSync } from "node:child_process"; +import { existsSync } from "node:fs"; +import { join } from "node:path"; + +// https://github.com/oven-sh/bun/issues/32489 +// +// Android's per-app seccomp policy does not whitelist epoll_pwait2, and on +// some shimmed-glibc setups the blocked syscall faults inside libc's +// syscall(2) error path instead of returning ENOSYS to the runtime fallback +// in epoll_kqueue.c. BUN_FEATURE_FLAG_DISABLE_EPOLL_PWAIT2 forces the loop to +// use epoll_pwait(2) so epoll_pwait2 is never issued. +// +// This test installs a seccomp filter that kills the process if epoll_pwait2 +// is ever called, sets the feature flag, and exercises both event loops that +// use bun_epoll_pwait2: a timer on the main loop and a fetch() on the HTTP +// thread. If the flag is honored, neither loop attempts the blocked syscall +// and the process exits 0. +describe.skipIf(!isLinux)("epoll_pwait2 disable gate", () => { + const helperSrc = ` +#define _GNU_SOURCE +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#if defined(__x86_64__) + #define MY_AUDIT_ARCH AUDIT_ARCH_X86_64 +#elif defined(__aarch64__) + #define MY_AUDIT_ARCH AUDIT_ARCH_AARCH64 +#else + #define MY_AUDIT_ARCH 0 +#endif + +#ifndef __NR_epoll_pwait2 +#define __NR_epoll_pwait2 441 +#endif + +#ifndef SECCOMP_RET_KILL_PROCESS +#define SECCOMP_RET_KILL_PROCESS 0x80000000U +#endif + +int main(int argc, char **argv) { + if (argc < 2) return 2; + if (MY_AUDIT_ARCH == 0) return 77; /* unsupported arch, skip */ + + /* The control run is deliberately killed by SIGSYS below; suppress the + * core file so the CI runner does not flag it as a crash. RLIMIT_CORE + * survives execvp. */ + struct rlimit no_core = {0, 0}; + if (setrlimit(RLIMIT_CORE, &no_core) != 0) { + perror("setrlimit(RLIMIT_CORE)"); + return 77; + } + + struct sock_filter filter[] = { + /* arch check */ + BPF_STMT(BPF_LD | BPF_W | BPF_ABS, offsetof(struct seccomp_data, arch)), + BPF_JUMP(BPF_JMP | BPF_JEQ | BPF_K, MY_AUDIT_ARCH, 1, 0), + BPF_STMT(BPF_RET | BPF_K, SECCOMP_RET_ALLOW), + /* load syscall nr */ + BPF_STMT(BPF_LD | BPF_W | BPF_ABS, offsetof(struct seccomp_data, nr)), + /* if nr == __NR_epoll_pwait2 -> kill the whole process */ + BPF_JUMP(BPF_JMP | BPF_JEQ | BPF_K, __NR_epoll_pwait2, 0, 1), + BPF_STMT(BPF_RET | BPF_K, SECCOMP_RET_KILL_PROCESS), + /* else -> allow */ + BPF_STMT(BPF_RET | BPF_K, SECCOMP_RET_ALLOW), + }; + struct sock_fprog prog = { + .len = (unsigned short)(sizeof(filter) / sizeof(filter[0])), + .filter = filter, + }; + + if (prctl(PR_SET_NO_NEW_PRIVS, 1, 0, 0, 0) != 0) { + perror("prctl(PR_SET_NO_NEW_PRIVS)"); + return 77; /* cannot install filter, skip */ + } + if (syscall(__NR_seccomp, SECCOMP_SET_MODE_FILTER, 0, &prog) != 0) { + perror("seccomp"); + return 77; /* cannot install filter, skip */ + } + + execvp(argv[1], &argv[1]); + perror("execvp"); + return 127; +} +`; + + // Compile the seccomp helper once. Returns the binary path, or null if + // the host genuinely can't build it (no cc, missing kernel headers). + // Any other compile failure throws so a source regression isn't silently + // hidden as a skip. + const tryBuild = (): string | null => { + const dir = tempDirWithFiles("epoll-pwait2-seccomp", { + "kill_epoll_pwait2.c": helperSrc, + }); + const src = join(dir, "kill_epoll_pwait2.c"); + const bin = join(dir, "kill_epoll_pwait2"); + const compile = spawnSync("cc", ["-O0", "-o", bin, src], { stdio: "pipe" }); + + // compiler not on PATH — expected skip + if ((compile.error as NodeJS.ErrnoException | undefined)?.code === "ENOENT") return null; + + if (compile.status !== 0) { + const stderr = compile.stderr?.toString() ?? ""; + // missing linux/*.h on the host — expected skip + if (/linux\/(seccomp|filter|audit)\.h|sys\/prctl\.h/.test(stderr)) return null; + throw new Error(`failed to compile seccomp helper:\n${stderr}`); + } + if (!existsSync(bin)) { + throw new Error("seccomp helper compiled successfully but output binary is missing"); + } + return bin; + }; + + const helperBin = tryBuild(); + + // Run `snippet` in a bun subprocess guarded by the seccomp helper. + // Returns null if the environment refused to install the seccomp filter + // (skip). + async function runUnderSeccomp(bin: string, snippet: string, disableEpollPwait2: boolean) { + await using proc = Bun.spawn({ + cmd: [bin, bunExe(), "-e", snippet], + env: { + ...bunEnv, + BUN_FEATURE_FLAG_DISABLE_EPOLL_PWAIT2: disableEpollPwait2 ? "1" : undefined, + }, + stdout: "pipe", + stderr: "pipe", + }); + const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); + if (exitCode === 77) return null; + return { stdout, stderr, exitCode, signalCode: proc.signalCode }; + } + + const cases: Array<{ name: string; snippet: string; expected: string }> = [ + { + name: "main loop", + // The setTimeout forces us_loop_run_bun_tick to wait on the epoll fd + // with a finite timeout, exercising bun_epoll_pwait2 on the main loop. + snippet: `await new Promise(r => setTimeout(r, 50)); + console.log("timer-ok");`, + expected: "timer-ok", + }, + { + name: "HTTP thread loop", + // fetch() runs on the dedicated HTTP thread, which owns its own + // us_loop_t; this exercises bun_epoll_pwait2 on that loop as well + // (the frame the issue reported faulting: HTTPThread.rs -> + // us_loop_run_bun_tick). + snippet: `await using server = Bun.serve({ port: 0, fetch: () => new Response("pong") }); + const res = await fetch(server.url); + console.log("http-thread-ok:" + await res.text() + ":" + res.status);`, + expected: "http-thread-ok:pong:200", + }, + ]; + + for (const c of cases) { + test(`BUN_FEATURE_FLAG_DISABLE_EPOLL_PWAIT2 gates the ${c.name}`, async () => { + if (helperBin == null) { + console.warn("SKIP epoll_pwait2 seccomp: cc or seccomp headers not available"); + return; + } + + // Control run: same snippet WITHOUT the flag. Proves the seccomp + // filter is live and this path actually issues epoll_pwait2 on this + // host (kernel >= 5.11, not Android). If the control is not killed, + // the gate under test is already disabled by a different condition + // and the flagged run below would pass for the wrong reason. + const control = await runUnderSeccomp(helperBin, c.snippet, false); + if (control == null) { + console.warn("SKIP epoll_pwait2 seccomp: seccomp not permitted in this environment"); + return; + } + if (control.signalCode !== "SIGSYS") { + console.warn( + `SKIP epoll_pwait2 seccomp: control run was not killed ` + + `(signal=${control.signalCode} exit=${control.exitCode}); ` + + `epoll_pwait2 is already disabled on this host`, + ); + return; + } + + const out = await runUnderSeccomp(helperBin, c.snippet, true); + if (out == null) { + console.warn("SKIP epoll_pwait2 seccomp: seccomp not permitted in this environment"); + return; + } + + expect({ stdout: out.stdout.trim(), signalCode: out.signalCode }).toEqual({ + stdout: c.expected, + signalCode: null, + }); + if (out.exitCode !== 0) expect(out.stderr).toBe(""); + expect(out.exitCode).toBe(0); + }); + } +});