Skip to content
Merged
2 changes: 1 addition & 1 deletion packages/bun-usockets/src/eventing/epoll_kqueue.c
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}

Expand Down
14 changes: 12 additions & 2 deletions src/analytics/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
7 changes: 7 additions & 0 deletions src/bun_core/env_var.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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", {});
Comment thread
robobun marked this conversation as resolved.
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
Expand Down
87 changes: 67 additions & 20 deletions src/platform/linux.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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)]
Expand All @@ -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,
Expand All @@ -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)
}
}
206 changes: 206 additions & 0 deletions test/regression/issue/32489.test.ts
Original file line number Diff line number Diff line change
@@ -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 <errno.h>
#include <linux/audit.h>
#include <linux/filter.h>
#include <linux/seccomp.h>
#include <stddef.h>
#include <stdio.h>
#include <stdlib.h>
#include <sys/prctl.h>
#include <sys/resource.h>
#include <sys/syscall.h>
#include <unistd.h>

#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;
}

Comment thread
coderabbitai[bot] marked this conversation as resolved.
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);
Comment thread
claude[bot] marked this conversation as resolved.
});
}
});
Loading