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

Status Open
Reported on v2.1.289
Maintainer reply None cached
Activity 0 comments · opened Oct 5, 2026

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 ALL plus CHOWN/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 Pool threads 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:

  • futex 248k calls
  • epoll_pwait 28.6k
  • write 49.6k, all to the event loop's eventfd
  • openat 24.8k
  • readlink 24.8k, all failing
  • newfstatat 24.8k
  • close 24.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

  1. The session process makes itself non-dumpable. While it runs, /proc/<pid>/fd is

root:root 0500. The parent runner's and other processes' entries in the same
container are owned by the session user.

  1. 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.

  1. Bun's async realpath runs 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.

  1. 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 /proc not 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 realpath could 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_SEARCH would 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.

View original on GitHub ↗