Handle an unavailable statx, faccessat2 or prlimit64 the way Node does - #42688
Conversation
- fs.stat: report ctime as birthtime when stat(2) has to stand in for statx(2), like libuv's uv__to_stat. It was the epoch. - exists_at/faccessat on Linux: issue the flag-less faccessat syscall directly. glibc 2.33+ faccessat() tries faccessat2 first and falls back on ENOSYS only, so a seccomp filter that answers faccessat2 with EPERM made a hoisted install report a cached package as missing. - RealFS::init: a failed getrlimit(RLIMIT_NOFILE) is an unknown fd budget, not a panic. - process.report: leave out a limit that getrlimit cannot read instead of printing an uninitialized struct rlimit.
|
Status: fix pushed, waiting for CI. Reproduced with a small seccomp launcher (the helper in
Run the test file with |
|
No actionable comments were generated in the recent review. 🎉 ℹ️ Recent review info⚙️ Run configurationConfiguration used: Path: .coderabbit.yaml Review profile: ASSERTIVE Plan: Essentials Run ID: 📒 Files selected for processing (6)
Included review availability: Your plan provides up to 10 included reviews per hour; 3 remain after this review. WalkthroughThe change adds Linux syscall failure handling for stat metadata, path existence checks, and resource limits. It also extends seccomp tests for statx, faccessat2, and prlimit64 failures. ChangesLinux failure handling
Suggested reviewers: Priority: ➖ Normal Merge Risk: ⚪ Minimal · up to No actionable merge-blocking behavior remains from the reviewed change. 🚥 Pre-merge checks | ✅ 4✅ Passed checks (4 passed)
Comment |
There was a problem hiding this comment.
I reviewed this PR and didn't find any bugs. Because it reroutes exists_at — a hot path hit on every Linux install/resolve — from glibc's faccessat to a direct rustix syscall, a human sign-off on the syscall routing would still be worthwhile.
What was reviewed:
linux_syscall.rsfaccessatwrapper — matches the file's existing rustix pattern (retry,as_borrowed_fd, positive-errnoResult); emptyAtFlagsis the documented condition for rustix to issue__NR_faccessatdirectly.adjust_ulimit— the remaining.expect("int cast")is on a successfully-readrlim_cur, not the removed OS-failure path; return-0 fallback is consumed only byneed_to_close_files.BunProcess.cpp—getrlimitreturn now checked before readinglimit, matching Node's skip-on-failure;stat_birthtimectime fallback matches libuvuv__to_stat.- Tests — hermetic (local
Bun.serve({port:0})registry,tempDir,bunEnvspread),test.concurrent,isASAN-gated where the sanitizer runtime itself aborts.
Extended reasoning...
Overview
This PR hardens Bun against seccomp filters that block newer Linux syscalls (statx, faccessat2, prlimit64) with errnos other than ENOSYS. It touches four native areas: src/sys/lib.rs and src/sys/linux_syscall.rs reroute exists_at/faccessat on Linux from glibc's wrapper (which tries faccessat2 first since 2.33) to a direct rustix __NR_faccessat call; src/resolver/lib.rs removes the .expect("unreachable") on getrlimit(RLIMIT_NOFILE) so startup no longer panics under a prlimit64 filter; src/jsc/bindings/BunProcess.cpp checks getrlimit's return before reading its out-param in process.report; and src/sys/PosixStat.rs returns ctime for birthtime on Linux's struct stat path (statx unavailable), matching libuv. Ten new seccomp tests cover each case.
Security risks
None identified. The changes make syscall failures degrade gracefully rather than panic or read uninitialized memory. exists_at remains a boolean predicate (any failure → false), same contract as before. No new user-controlled input reaches the kernel; the rustix call receives the same dir fd, path, and F_OK mode as the libc call did. The process.report fix removes an uninitialized-read, which is strictly a hardening.
Level of scrutiny
Moderate-to-high. Each individual change is small and well-justified against upstream (libuv uv__to_stat, Node PlatformInit), and the new linux_syscall::faccessat follows the file's established rustix pattern line-for-line. However, exists_at is called on effectively every path the resolver and package manager touch on Linux — rerouting it from glibc to a raw syscall changes behavior for all Linux users, not just those under seccomp. The correctness hinges on rustix's accessat with empty AtFlags issuing __NR_faccessat (not faccessat2) on the linux_raw backend and on bionic's flag-less faccessat on Android; the code comment asserts this and it matches rustix's documented behavior, but a maintainer familiar with the rustix backends should confirm.
Other factors
No CODEOWNERS cover these paths. The PR is exceptionally well-documented (self-review notes name two follow-ups and one intentionally-excluded same-class site, lchmod, with a reason). Tests are hermetic, concurrent, and gated appropriately (isASAN skip where the sanitizer runtime itself aborts on getrlimit(RLIMIT_STACK) failure; per-arg1 BPF filter so the RLIMIT_NOFILE tests still run under ASAN). The usize::try_from(lim.cur).expect("int cast") that remains in adjust_ulimit is on a successfully-read kernel value, not the OS-failure path this PR addresses. Given the hot-path syscall reroute, deferring for a human sign-off is the safer call despite the high quality of the change.
|
Updated 1:24 AM PT - Sep 14th, 2026
❌ @robobun, your commit 09e9090 has 1 failures in
🧪 To try this PR locally: bunx bun-pr 42688That installs a local version of the PR into your bun-42688 --bun |
oven-sh#42688) ### Problem - `statx(2)` unavailable (kernel before 4.11, or seccomp): `fs.stat` reports `birthtime` 1970-01-01. Node reports ctime. `stat_birthtime` (`src/sys/PosixStat.rs`) returned the epoch on Linux. - `faccessat2` answers EPERM or EINVAL: a hoisted `bun install` from a warm cache fails with `error: failed to install dep: the downloaded package was not found in the cache`. glibc 2.33+ `faccessat()` issues `faccessat2` first, even with flags 0, and falls back on ENOSYS only. `exists_at` (`src/sys/lib.rs`) called it. bun 1.3.14 passes. - `prlimit64` denied: every command dies with `panic: unreachable: Sys(EPERM)`, from `adjust_ulimit().expect("unreachable")` in `RealFS::init` (`src/resolver/lib.rs`). bun 1.3.14 does not. ### Fix - `stat_birthtime` returns ctime on Linux, as libuv `uv__to_stat` does. - `exists_at` and `faccessat` issue the flag-less `faccessat` syscall on Linux, through rustix. bun never passes flags. musl and older glibc do the same. - A failed `getrlimit(RLIMIT_NOFILE)` gives an fd budget of 0: the resolver keeps no fds open. `process.report` leaves out a limit it cannot read (it printed an uninitialized `rlimit`). Node does both. - Limit: with all of `prlimit64` denied, `bun install`, `bun build` and `bun run <script>` work again. JavaScript still cannot start, as on 1.3.14 (see notes). - Verified: `test/js/node/fs/fs-stat-seccomp-linux.test.ts`, 10 new tests, all fail on bun 1.4.2. Self-reviewed: 11 concerns, 9 addressed, 2 are follow-ups (notes). ### Background - seccomp is a per-process syscall filter. A filtered call fails with an errno that the profile chooses. - `statx` is the only Linux stat call with a birth time. - `faccessat2` (Linux 5.8) is `faccessat` plus a flags argument. - glibc implements `getrlimit` with `prlimit64`. <details><summary>Notes</summary> **Origin.** A sweep where one syscall answers ENOSYS, EPERM or EINVAL for the whole process tree (a seccomp filter), with node v26.3.0 as the reference. There is no GitHub issue. All three reproduce here on bun 1.4.2 and on a release build of main, with the seccomp launcher from the test file. **Versions.** The test file on three builds: this branch (release build) 15 pass. bun 1.4.2: the 10 new tests fail. bun 1.3.14: the `faccessat2`, `RLIMIT_NOFILE` and `bun build` tests pass, the birthtime and `process.report` tests fail. So the `faccessat2` and `prlimit64` members regressed in 1.4.0. The birthtime and `process.report` members were never right. **statx.** ENOSYS, EPERM and EINVAL behave the same. Every path that converts a plain `struct stat` goes through `PosixStat::init`, so the statx fallback, the node:fs path that skips statx (after `SUPPORTS_STATX_ON_LINUX` flips) and the stat watcher change together. When statx works but the filesystem does not report `STATX_BTIME`, bun still reports 0. libuv does the same (`uv__statx_to_stat` copies `stx_btime` as it is). **faccessat2.** ENOSYS already worked because glibc falls back by itself. `--linker isolated` worked because it uses `directory_exists_at` (fstatat). The `exists_at` contract is unchanged: it is a predicate and every failure is "no", like `fs.existsSync`. The cause is the syscall that glibc picks, not the errno folding. rustix `accessat` with empty flags goes straight to `__NR_faccessat` on its linux_raw backend (x64 and arm64, glibc and musl). On Android rustix uses its libc backend, and bionic `faccessat` is already flag-less. A realistic host for this is a seccomp profile that was patched for `clone3` but not for `faccessat2`. An unpatched old Docker host stops earlier, when `clone3` answers EPERM. **prlimit64.** - The strace line in the report shows `RLIMIT_STACK` because that is the first `getrlimit` glibc makes. The panic is the `RLIMIT_NOFILE` read in `adjust_ulimit`. - With an fd budget of 0, `--watch` and `--hot` only see the entry point. They register an imported module through its cached fd. This is only reachable with a filter that lets `RLIMIT_STACK` through. - With all of `prlimit64` denied, JSC stops in `VM::setLastStackTop` (RELEASE_ASSERT). glibc `pthread_getattr_np` needs `getrlimit(RLIMIT_STACK)` for the main thread, so `WTF::StackBounds` gets no bounds. bun 1.3.14 stops at the same place. There are two ways to close it, and neither is in this PR. One is a change to `StackBounds.cpp` in the WebKit fork. The other is link-time wraps (next to `__wrap_execve`) for `getrlimit` and `pthread_getattr_np`: the first falls back to the legacy `getrlimit` syscall (x64 only), the second derives the main thread stack from `__libc_stack_end`. An `LD_PRELOAD` simulation of both wraps lets bun run JavaScript under the filter. The `getrlimit` wrap alone does not. - The `RLIMIT_NOFILE` tests use a filter on the second syscall argument so that they also run under ASAN. The ASAN runtime aborts by itself when `getrlimit(RLIMIT_STACK)` fails (`sanitizer_posix_libcdep.cpp:88`). The `bun build` tests deny the whole syscall and skip under ASAN. **Same-class sites not changed here.** - `lchmod` on Linux (`src/sys/lib.rs`) issues `fchmodat2` and falls back on ENOSYS only. EPERM is also a real `chmod` error there, so it needs its own fix with a fallback that does not go through libc. Node has no `lchmod` on Linux. - `getcwd().expect("unreachable")` in `src/install/lockfile/Package/WorkspaceMap.rs` and `self_exe_path().expect("unreachable")` in `src/bun_core/util.rs` also unwrap an OS result. They are not about a newer syscall. **Self-review follow-ups not in this PR.** Move the seccomp launcher from the test file into the test harness. Add a test on a real 3.10 kernel. **Other suites run with the debug build.** `fs-birthtime-linux.test.ts`, `fs-stats-truncate.test.ts`, `fs-stats-constructor.test.ts`, `fs.test.ts`, `fs.watchFile.test.ts`, `bun-pm-version.test.ts`, `bun-install-offline.test.ts`, `bun-create.test.ts`, `hot.test.ts`, `bun-install.test.ts` (13 failures there need gitlab.com and bitbucket.org, and fail the same way on 1.4.2 in this sandbox). `cargo check -p bun_sys -p bun_resolver` passes for linux gnu and musl (x64, arm64), android (x64, arm64), macOS arm64, FreeBSD x64 and Windows x64. </details> <!-- robobun:evidence:begin --> --- **no test proof** · iteration 0 · platform-specific test(s) that do not run on this machine, deferring to CI, which covers all platforms: test/js/node/fs/fs-stat-seccomp-linux.test.ts <!-- robobun:evidence:end -->
…ch64 Upstream highlights: - js_printer: infallible writer ops, output-position fixes, minify fixes - mimalloc to the head of bun-dev3-v2; statx/faccessat2/prlimit64 handled like Node (oven-sh#42688) - Terminal: reader ref taken before start, writer-done inside write() (oven-sh#42654, oven-sh#42662) - build: post-link verify-binary checks, LTO default on for every release build, llvm-tools component, WebKit 9b02218df6 - fetch/http2/h3, streams, bundler, react-compiler, node compat fixes Conflicts (6), resolved keeping both sides: - scripts/build/flags.ts: keep `c.ohos` gnu++23, take upstream comment/desc - scripts/build/tools.ts: keep OHOS llvm-nm 15 guard, add upstream llvm-readobj/objdump/cxxfilt lookups - src/runtime/api/bun/Terminal.rs: keep the OHOS deferred-exit replay (read after callbacks); upstream's READER_DONE throw is applied on non-OHOS only, because on OHOS the pty reader's poll registration can fail in normal operation and the exit callback must still fire (T03b) - test/cli/test/isolation.test.ts: keep the isOhos skip on the socket test, add upstream's transpiler-cache namespace tests - heapStats-mimalloc/vm tests: import unions (isOhos + tempDir) OHOS adaptations: - every OHOS build entry point pins --lto=off: upstream now defaults ThinLTO on for all release builds, which flips WebKit's CMAKE_BUILD_TYPE (RelWithDebInfo -> Release) and invalidates the existing WebKit build dir. OHOS has never been validated with LTO; keeps the previous codegen. - scripts/build/source.ts: OHOS dependency objects get -fPIC like Android. Upstream extended the "-fno-pic -fno-pie on unix" default to every unix target; on OHOS (a PIE-only loader) that linked non-PIC dep objects into the PIE binary, producing R_AARCH64_COPY relocations for libc data that OHOS musl does not populate -> the freshly built binary aborted at startup (found by this merge's smoke test). - docs/ohos-adaptation-checklist.md: record the LTO default change, the PIC policy, and the removed node-gyp -Wl,--code-sign. Impact assessment (complete, not sampled): - all 347 files the merge touches were checked for lost `ohos` references; only the flags.ts desc reword and two intentional import merges differ - all 34 files both sides changed were checked line-by-line: every line the OHOS side added since 3f7f046 is still present - every marker in docs/ohos-adaptation-checklist.md verified present in the merged tree (spawn drain gates, memfd gates, syscall fallbacks, IS_NODE_ARG, lifecycle PATH injection, webkit OHOS cmake block, ...)
Problem
statx(2)unavailable (kernel before 4.11, or seccomp):fs.statreportsbirthtime1970-01-01. Node reports ctime.stat_birthtime(src/sys/PosixStat.rs) returned the epoch on Linux.faccessat2answers EPERM or EINVAL: a hoistedbun installfrom a warm cache fails witherror: failed to install dep: the downloaded package was not found in the cache. glibc 2.33+faccessat()issuesfaccessat2first, even with flags 0, and falls back on ENOSYS only.exists_at(src/sys/lib.rs) called it. bun 1.3.14 passes.prlimit64denied: every command dies withpanic: unreachable: Sys(EPERM), fromadjust_ulimit().expect("unreachable")inRealFS::init(src/resolver/lib.rs). bun 1.3.14 does not.Fix
stat_birthtimereturns ctime on Linux, as libuvuv__to_statdoes.exists_atandfaccessatissue the flag-lessfaccessatsyscall on Linux, through rustix. bun never passes flags. musl and older glibc do the same.getrlimit(RLIMIT_NOFILE)gives an fd budget of 0: the resolver keeps no fds open.process.reportleaves out a limit it cannot read (it printed an uninitializedrlimit). Node does both.prlimit64denied,bun install,bun buildandbun run <script>work again. JavaScript still cannot start, as on 1.3.14 (see notes).test/js/node/fs/fs-stat-seccomp-linux.test.ts, 10 new tests, all fail on bun 1.4.2. Self-reviewed: 11 concerns, 9 addressed, 2 are follow-ups (notes).Background
statxis the only Linux stat call with a birth time.faccessat2(Linux 5.8) isfaccessatplus a flags argument.getrlimitwithprlimit64.Notes
Origin. A sweep where one syscall answers ENOSYS, EPERM or EINVAL for the whole process tree (a seccomp filter), with node v26.3.0 as the reference. There is no GitHub issue. All three reproduce here on bun 1.4.2 and on a release build of main, with the seccomp launcher from the test file.
Versions. The test file on three builds: this branch (release build) 15 pass. bun 1.4.2: the 10 new tests fail. bun 1.3.14: the
faccessat2,RLIMIT_NOFILEandbun buildtests pass, the birthtime andprocess.reporttests fail. So thefaccessat2andprlimit64members regressed in 1.4.0. The birthtime andprocess.reportmembers were never right.statx. ENOSYS, EPERM and EINVAL behave the same. Every path that converts a plain
struct statgoes throughPosixStat::init, so the statx fallback, the node:fs path that skips statx (afterSUPPORTS_STATX_ON_LINUXflips) and the stat watcher change together. When statx works but the filesystem does not reportSTATX_BTIME, bun still reports 0. libuv does the same (uv__statx_to_statcopiesstx_btimeas it is).faccessat2. ENOSYS already worked because glibc falls back by itself.
--linker isolatedworked because it usesdirectory_exists_at(fstatat). Theexists_atcontract is unchanged: it is a predicate and every failure is "no", likefs.existsSync. The cause is the syscall that glibc picks, not the errno folding. rustixaccessatwith empty flags goes straight to__NR_faccessaton its linux_raw backend (x64 and arm64, glibc and musl). On Android rustix uses its libc backend, and bionicfaccessatis already flag-less. A realistic host for this is a seccomp profile that was patched forclone3but not forfaccessat2. An unpatched old Docker host stops earlier, whenclone3answers EPERM.prlimit64.
RLIMIT_STACKbecause that is the firstgetrlimitglibc makes. The panic is theRLIMIT_NOFILEread inadjust_ulimit.--watchand--hotonly see the entry point. They register an imported module through its cached fd. This is only reachable with a filter that letsRLIMIT_STACKthrough.prlimit64denied, JSC stops inVM::setLastStackTop(RELEASE_ASSERT). glibcpthread_getattr_npneedsgetrlimit(RLIMIT_STACK)for the main thread, soWTF::StackBoundsgets no bounds. bun 1.3.14 stops at the same place. There are two ways to close it, and neither is in this PR. One is a change toStackBounds.cppin the WebKit fork. The other is link-time wraps (next to__wrap_execve) forgetrlimitandpthread_getattr_np: the first falls back to the legacygetrlimitsyscall (x64 only), the second derives the main thread stack from__libc_stack_end. AnLD_PRELOADsimulation of both wraps lets bun run JavaScript under the filter. Thegetrlimitwrap alone does not.RLIMIT_NOFILEtests use a filter on the second syscall argument so that they also run under ASAN. The ASAN runtime aborts by itself whengetrlimit(RLIMIT_STACK)fails (sanitizer_posix_libcdep.cpp:88). Thebun buildtests deny the whole syscall and skip under ASAN.Same-class sites not changed here.
lchmodon Linux (src/sys/lib.rs) issuesfchmodat2and falls back on ENOSYS only. EPERM is also a realchmoderror there, so it needs its own fix with a fallback that does not go through libc. Node has nolchmodon Linux.getcwd().expect("unreachable")insrc/install/lockfile/Package/WorkspaceMap.rsandself_exe_path().expect("unreachable")insrc/bun_core/util.rsalso unwrap an OS result. They are not about a newer syscall.Self-review follow-ups not in this PR. Move the seccomp launcher from the test file into the test harness. Add a test on a real 3.10 kernel.
Other suites run with the debug build.
fs-birthtime-linux.test.ts,fs-stats-truncate.test.ts,fs-stats-constructor.test.ts,fs.test.ts,fs.watchFile.test.ts,bun-pm-version.test.ts,bun-install-offline.test.ts,bun-create.test.ts,hot.test.ts,bun-install.test.ts(13 failures there need gitlab.com and bitbucket.org, and fail the same way on 1.4.2 in this sandbox).cargo check -p bun_sys -p bun_resolverpasses for linux gnu and musl (x64, arm64), android (x64, arm64), macOS arm64, FreeBSD x64 and Windows x64.no test proof · iteration 0 · platform-specific test(s) that do not run on this machine, deferring to CI, which covers all platforms: test/js/node/fs/fs-stat-seccomp-linux.test.ts