Session process spins at ~2.3 CPUs on kernel 3.10: async `realpath` fails with EACCES after the process becomes non-dumpable, and the caller retries forever
Environment
- Claude Code 2.1.289 (native installer). The binary embeds Bun v1.4.3.
- Self-hosted environment runner (
claude self-hosted-runner,--capacity 1) in a
Docker container.
- Container: Ubuntu 22.04, non-root user,
--cap-drop ALLplusCHOWN/SETUID/SETGID,
--security-opt no-new-privileges, Docker's default seccomp profile.
- Host: CentOS 7, kernel
3.10.0-514.6.1.el7.x86_64, Docker 23.0.1.
What happens
For a session's whole life, the session process
(claude --print --sdk-url https://api.anthropic.com/v1/code/sessions/… --input-format stream-json …)
uses about 225% CPU, even when the session is idle:
- the main thread at 100% (21m50s of CPU in 22m11s of runtime)
- eight
Bun Poolthreads at about 13% each - about 556,000 voluntary context switches in 22 minutes.
The session still works; the CPU is wasted. The parent claude self-hosted-runner
process uses 0.2%.
strace -f -c over 5 s, in descending order:
futex248k callsepoll_pwait28.6kwrite49.6k, all to the event loop's eventfdopenat24.8kreadlink24.8k, all failingnewfstatat24.8kclose24.8k.
Each pool thread runs this sequence over and over:
openat(AT_FDCWD, "/", O_RDONLY|O_CLOEXEC|O_PATH) = 13
readlink("/proc/self/fd/13", ..., 4096) = -1 EACCES (Permission denied)
close(13)
write(5, "\1\0\0\0\0\0\0\0", 8) # wake the event loop
newfstatat(AT_FDCWD, "/", {st_mode=S_IFDIR|0755, ...}, 0) = 0
realpath("/") is called about 9,000 times a second, always on /.
Cause
- The session process makes itself non-dumpable. While it runs,
/proc/<pid>/fdis
root:root 0500. The parent runner's and other processes' entries in the same
container are owned by the session user.
- On this kernel, a non-dumpable process can read its own
/proc/self/fd**only from
the main thread**. Any other thread of the same process gets EACCES.
- Bun's async
realpathruns on the thread pool and resolves the path with
open(O_PATH) + readlink("/proc/self/fd/N"), so it always fails with EACCES.
The sync version runs on the main thread and works.
- Something in Claude Code retries the failed lookup with no limit or backoff.
Reproduction
No Claude Code session needed. This uses stock Bun 1.4.2 (the latest release; Claude
Code embeds 1.4.3), in a container on the host above, running as a non-root user:
// repro.js
import { dlopen, FFIType } from "bun:ffi";
import { realpathSync, promises as fsp } from "node:fs";
const { symbols } = dlopen("libc.so.6", {
prctl: { args: [FFIType.i32, FFIType.u64, FFIType.u64, FFIType.u64, FFIType.u64], returns: FFIType.i32 },
});
const show = async (label) => {
try { console.log(label, "realpathSync: ", realpathSync("/")); } catch (e) { console.log(label, "realpathSync: ", e.code); }
try { console.log(label, "fs.promises.realpath:", await fsp.realpath("/")); } catch (e) { console.log(label, "fs.promises.realpath:", e.code); }
};
await show("dumpable ");
symbols.prctl(4, 0, 0, 0, 0); // PR_SET_DUMPABLE = 0
await show("non-dumpable");
$ bun repro.js
dumpable realpathSync: /
dumpable fs.promises.realpath: /
non-dumpable realpathSync: /
non-dumpable fs.promises.realpath: EACCES
The kernel side, in Python, without Bun:
import ctypes, os, threading
ctypes.CDLL(None).prctl(4, 0, 0, 0, 0) # PR_SET_DUMPABLE = 0
def t(where):
fd = os.open("/", os.O_RDONLY)
try: print(where, os.readlink(f"/proc/self/fd/{fd}"))
except OSError as e: print(where, e)
t("main ") # main /
threading.Thread(target=t, args=("thread",)).start() # thread [Errno 13] Permission denied
We haven't tested a newer kernel. Most installs run on one, which is probably why this
hasn't come up before. RHEL/CentOS 7 (3.10) is still common on HPC and on-prem hosts.
Expected
- A failed
realpath(EACCES, ENOENT, or/procnot mounted) should not lead to an
unbounded retry loop. The caller should give up, back off, or use the unresolved path.
- Separately, Bun's async
realpathcould fall back to resolving the path component by
component when the /proc/self/fd lookup fails, as the sync version effectively does
on the main thread. That may be worth a separate oven-sh/bun issue; see below.
Workarounds we ruled out
CAP_DAC_READ_SEARCHwould let the threads traverse/proc/self/fd, but it also
allows open_by_handle_at, a known container escape. We won't grant it.
- A newer host kernel means a reboot and OS upgrade of a shared HPC node; not possible
for now.
Is there a setting that keeps the session process dumpable, or that avoids the lookup?
Either would be enough for us in the meantime.