Skip to content

[Linux] Read OS randomness with getrandom(2) and open /dev/urandom only when it fails - #749

Merged
dylan-conway merged 4 commits into
mainfrom
claude/linux-getrandom
Sep 30, 2026
Merged

dylan-conway merged 4 commits into
mainfrom
claude/linux-getrandom

Conversation

@dylan-conway

@dylan-conway dylan-conway commented Sep 30, 2026 •

Copy link
Copy Markdown
Member

On Linux, JSC reads OS randomness with getrandom(2) and opens /dev/urandom only when the syscall fails. Replaces #394.

Why

WTF::RandomDevice opens /dev/urandom in its constructor and CRASH()es if the open fails. It is constructed during VM creation, so Bun aborts before running any JS when:

  • /dev/urandom is not there (a filesystem sandbox, a minimal container root), or
  • there is no free descriptor left for it (ulimit -n 5 aborts Bun 1.4.2).

bmalloc::ARC4RandomNumberGenerator::stir() has the same open behind a RELEASE_BASSERT.

Why here and not in Bun

Both pieces are upstream code. Upstream's own Linux sandbox mounts a /dev (BubblewrapLauncher.cpp passes --dev /dev), so the crash is not reachable there. Bun is a CLI that gets started inside whatever sandbox, container or rlimit the user has. RandomDevice is private to WTF and there is no hook for an embedder to supply entropy, so nothing on the Bun side can avoid the open.

What changes

Both places try getrandom(GRND_NONBLOCK) on every read, and whenever it fails they run the code that is on main today: open /dev/urandom once, crash if that fails, read from it. That covers EAGAIN (pool not initialized, where /dev/urandom does not block), ENOSYS, and EPERM from a seccomp filter, including one installed after startup.

WTF::RandomDevice: on Linux the constructor no longer opens anything. cryptographicallyRandomValues() fills the buffer with getrandom, resuming after short counts. If a call fails, /dev/urandom is opened if it is not open yet, and the existing read loop, which is unchanged, fills the rest. The open needs no synchronization of its own: the device's one caller, ARC4RandomNumberGenerator::stir() in CryptographicallyRandomNumber.cpp, is WTF_REQUIRES_LOCK(m_lock), so reads are already serialized.

bmalloc::ARC4RandomNumberGenerator::stir() needs 128 bytes, and getrandom fills a request of up to 256 bytes whole or fails, so it needs no loop and no new state.

Both fallback opens gain O_CLOEXEC. syscall(SYS_getrandom, …) rather than the libc wrapper, which needs glibc 2.25. The getrandom blocks compile only where the headers define SYS_getrandom (kernel headers 3.17 and later); elsewhere the files build to the /dev/urandom code alone.

The bmalloc generator's only caller is Gigacage, which is compiled out under USE_MIMALLOC. So that half is live in the debug and ASAN builds and is not linked into a release Bun at all. It is included so those builds behave the same as release under a sandbox.

Behaviour changes

  • Linux, getrandom working: same kernel pool, one fewer open descriptor (two fewer in debug/ASAN builds), so later descriptor numbers shift down.
  • Linux, getrandom failing, at startup or later: main's /dev/urandom code, with the open at the first failure instead of at construction, plus O_CLOEXEC.
  • FreeBSD: O_CLOEXEC only. macOS and Windows: none.
  • Neither source available: still crashes, as before.

getrandom is not a new dependency for the process: Bun 1.4.2 already makes 4 getrandom calls before JSC opens /dev/urandom.

What #394 had that this does not

  • Options::useGetrandom, with RandomDevice::setUseGetrandom and bmalloc::setCryptoRandomUsesGetrandom. With it set to false the release jsc from [Linux] Prefer getrandom(2) over /dev/urandom; keep /proc/self/statm open #394 still makes 11 getrandom calls from other components, and a failing getrandom already falls back on its own, so there is no case it rescues. The setter also constructs bmalloc's generator on every platform, including release builds that otherwise never link it.
  • The cached /proc/self/statm descriptor in currentProcessMemoryStatus(). LinuxMemory in AvailableMemory.cpp already holds that file open from startup and every collection already reads it, so this was a second descriptor for the same RSS field. Heap::proportionalHeapSize only reaches it when RAM is under heapGrowthFunctionThresholdInMB (16 GB). Measured: 1.3 µs for fopen+fgets+fclose against 0.43 µs for pread, where the cheapest full collection measured was 180 µs. It also turned one failed open into no footprint reading for the life of the process. It is unrelated to sandbox startup: that function never crashed.

Hiding /proc still aborts, in StackBounds. That is #556.

Testing

Linux x64. Bun at bf42a525d59 built against this branch, as a release build (mimalloc) and a debug build (ASAN, libpas). /dev hidden with bwrap --bind / / --tmpfs /dev. getrandom failures forced with a seccomp filter that returns the errno.

case before release debug/ASAN
/dev/urandom opens on a normal start 1 release, 2 debug 0 0
/dev hidden abort runs runs
/dev hidden, 8 Workers abort runs runs
ulimit -n 5 abort runs not run
ulimit -n 4 abort abort (Bun needs more descriptors than that on its own) not run
getrandom → ENOSYS / EPERM / EAGAIN runs runs, opens /dev/urandom O_CLOEXEC runs, opens it twice O_CLOEXEC
getrandom → EPERM from a filter installed after startup, then a forced reseed runs runs, opens /dev/urandom at the reseed not run
getrandom → EPERM and /dev hidden crash crash crash
Math.random() differs between runs, on either source yes yes yes

The late filter is installed from JS with bun:ffi (prctl(PR_SET_SECCOMP)), followed by 450,000 new ShadowRealm() to draw the 1.6 MB that makes WTF's generator reseed. The first commit of this PR, which chose the source once in the constructor, aborts in that test. All results here are from the head commit, 1fd7048dd007.

A harness linked against the built RandomDevice.cpp.o, 3 runs, all passing:

  • 8 MB fills through getrandom are uniform and open nothing; an empty buffer returns.
  • 40 fills of 8 MB under a SIGALRM every 50 µs without SA_RESTART: all 40 first calls returned a short count and were resumed, every fill complete, /dev/urandom not opened. getrandom never returned EINTR here, so that branch is not exercised.
  • A filter installed mid-run, then 32 threads released together into the fallback, each call under a lock as in the real caller: every fill uniform, /dev/urandom opened exactly once, O_CLOEXEC. With the lock removed, which no caller does, 10 runs opened between 1 and 10 descriptors and every fill was still uniform.

The SYS_getrandom guard, checked with a shadow sys/syscall.h that includes the real one and undefines the macro: both files compile with -Werror with and without it, in the release and debug configurations, and the commit before the guard fails there with use of undeclared identifier 'SYS_getrandom'. Built without the macro, the objects have no reference to syscall, and the same harness passes 3 runs with /dev/urandom opened once at the first read. The Bun builds tested here are built with it.

Bun test files run on the release build: spawn, spawn-stdin-pipe-fd-leak, spawn-pipe-start-error, fs-open-cloexec, fs, worker, web-globals, randomUUIDv7, crypto-random, shell/leak, bun-jsc, socket, blob-write. Six of them also on the debug build. All pass except fs.test.ts "mkdtempSync() empty name", which creates a directory directly under / and gets EACCES as a non-root user. On an earlier commit's debug build one worker.test.ts test in "terminate() races and lifecycle edges" failed once on a loaded machine and the file passed on rerun; over 10 interleaved runs of that group that build passed 10, and a debug build without the change passed 9 (a 5 s timeout). It passes on the head commit.

Not run locally: musl, arm64, Android, macOS, Windows, FreeBSD. The Linux includes are the ones #394 already compiled in CI.

No test in this repo can cover it. The Bun PR that picks this up can assert that no /proc/self/fd/* link is /dev/urandom (1 on a release build today, 2 on debug, 0 with this).

@github-actions

github-actions Bot commented Sep 30, 2026 •

Copy link
Copy Markdown

Preview build of 1fd7048: autobuild-preview-pr-749-1fd7048d

@claude claude Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nothing blocking. The comments below are optional suggestions. There is no need to push a fix for them before merging.

Beyond the inline findings, I also checked the short-return handling: in RandomDevice.cpp the getrandom result feeds the existing accumulate loop (fine for requests over 256 bytes), and in CryptoRandom.cpp a short getrandom return falls through to the /dev/urandom loop, which overwrites the whole 128-byte buffer, so no partially filled buffer is consumed; static_assert(sizeof(randomness) <= 256) matches length = sizeof(randomness).

Extended reasoning...

The change swaps the Linux entropy source in WTF::RandomDevice and bmalloc's ARC4 generator from an always-open /dev/urandom descriptor to a raw SYS_getrandom syscall with /dev/urandom as fallback, adding O_CLOEXEC to the fallback opens. It touches the cryptographic randomness path used by Math.random, UUIDs and Gigacage, and Source/WTF and Source/bmalloc are CODEOWNERS-listed; two confirmed findings (post-startup seccomp denial now crashes, and unguarded SYS_getrandom on old kernel headers) are posted inline, so approval is not appropriate.

Additional findings (outside the current diff — GitHub can't attach inline comments there):

  • 🟡 Source/WTF/wtf/RandomDevice.cpp — Bun processes that restrict getrandom after startup now abort on their next random draw, where the base branch kept running. The probe at RandomDevice.cpp:101 succeeds once and drops the descriptor; every later call goes through syscall(SYS_getrandom, ..., 0) at RandomDevice.cpp:137 with no fallback. If that returns EPERM or ENOSYS (a seccomp filter installed via bun:ffi, a napi sandbox addon, or a hardening library after the VM is up) the check at RandomDevice.cpp:145 calls crashUnableToReadFromURandom(). Fix: on a non-EAGAIN/EINTR error from getrandom, lazily open /dev/urandom and continue the loop instead of crashing; apply the same to CryptoRandom.cpp:132.

    Why this was flagged

    Trigger: any code that installs a seccomp/BPF filter denying getrandom after the static RandomDevice in OSRandomSource.cpp:36 was constructed. Bun exposes prctl/seccomp to plain JS through bun:ffi, and npm sandboxing addons do the same, so this is reachable without patching Bun. On base, RandomDevice held an open /dev/urandom fd and read() from it was unaffected by a getrandom filter, so Math.random reseeds, crypto.randomUUID and randomBytes kept working. After this change m_fd stays -1 (RandomDevice.h:54), the loop at RandomDevice.cpp:137 issues syscall(SYS_getrandom, ..., 0), gets -1 with errno EPERM (or ENOSYS for SCMP_ACT_ERRNO(ENOSYS)), and RandomDevice.cpp:145-146 crashes the process. The author lists this as accepted, but the consequence is a hard CRASH() in a long-running server for a filter that base tolerated, and the remedy is cheap: on an unexpected errno fall back to opening /dev/urandom (O_CLOEXEC) and reading it, only crashing if that open also fails.

    Verification: In Source/WTF/wtf/RandomDevice.cpp the constructor probes once at line 101, leaving m_fd == -1. Every subsequent draw then takes the line 136-137 branch; on -1 the check at lines 144-146 tolerates only EAGAIN/EINTR, so EPERM/ENOSYS from a later filter calls crashUnableToReadFromURandom(). On the base branch reads went through read(m_fd, ...), which a getrandom filter does not affect, so the process kept running.

Comment thread Source/WTF/wtf/RandomDevice.cpp
@coderabbitai

coderabbitai Bot commented Sep 30, 2026 •

Copy link
Copy Markdown

Review in Change Stack →

Navigate logical layers of code changes, visualize relationships, and explore their blast radius.

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: ASSERTIVE

Plan: Essentials

Run ID: c4a6d358-340b-48ff-9090-e054ae8a5e1e

📥 Commits

Reviewing files that changed from the base of the PR and between f20ce77 and e193972.

📒 Files selected for processing (3)
  • Source/WTF/wtf/RandomDevice.cpp
  • Source/WTF/wtf/RandomDevice.h
  • Source/bmalloc/bmalloc/CryptoRandom.cpp

Included review availability: This review used your included allowance. 1 included review remains after this review. Your included PR review attempts over the past 7 days set your current allowance at 3 reviews per hour.


Walkthrough

Linux builds now use getrandom in RandomDevice and ARC4RandomNumberGenerator, with /dev/urandom fallbacks. The changes also add O_CLOEXEC to fallback opens.

Changes

Random source integration

Layer / File(s) Summary
RandomDevice Linux source selection
Source/WTF/wtf/RandomDevice.cpp, Source/WTF/wtf/RandomDevice.h
Linux construction probes getrandom without blocking. Successful probes select getrandom for reads; failed probes use /dev/urandom opened with O_CLOEXEC. The destructor closes only a valid descriptor.
ARC4RandomNumberGenerator stir source selection
Source/bmalloc/bmalloc/CryptoRandom.cpp
On Linux, stir() requests the full buffer through nonblocking getrandom. Other results use /dev/urandom, opened with O_CLOEXEC.

Priority: ⬇️ Low

Merge Risk: ⚪ Minimal · up to e1939

This change reads Linux randomness through getrandom and opens /dev/urandom only when the syscall fails. That should stop startup aborts when /dev cannot be opened or file descriptors are exhausted. No actionable merge-blocking risk was found.

🚥 Pre-merge checks | ✅ 3 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Description check ⚠️ Warning The description provides a detailed explanation, rationale, implementation summary, behavior changes, and testing results. However, it does not include the required Bugzilla issue link, Reviewed by NO… Add the associated Bugzilla URL, include the required review line, and provide the changed-file and function list in the repository template format. Ensure the pull request is linked to the Bugzilla issue and has the required labels and ass…
✅ Passed checks (3 passed)
Check name Status Explanation
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
Title check ✅ Passed The title clearly and concisely describes the primary Linux change: using getrandom(2) and opening /dev/urandom only when needed.
Full details: Description check

Explanation

The description provides a detailed explanation, rationale, implementation summary, behavior changes, and testing results. However, it does not include the required Bugzilla issue link, Reviewed by NOBODY (OOPS!), or the template-formatted changed-file and function list.

Resolution

Add the associated Bugzilla URL, include the required review line, and provide the changed-file and function list in the repository template format. Ensure the pull request is linked to the Bugzilla issue and has the required labels and assignment.

  • Fix all pre-merge checks with AI
  • Autopilot · Keep fixing CodeRabbit findings and required CI, and resolving merge conflicts

Autopilot is currently an internal CodeRabbit preview.

Warning

Git: CodeRabbit could not clone the repository, so clone-backed analysis was skipped and this review may be incomplete. Verify repository clone access, such as SSH credentials, before requesting another full review. If clone access is intentionally unavailable, use path_filters to narrow the review scope.


Comment @coderabbitai help to get the list of available commands.

@dylan-conway

Copy link
Copy Markdown
Member Author

On the finding about a filter that starts denying getrandom after startup: fixed in 1cdec68. RandomDevice now tries getrandom on every read and runs the existing /dev/urandom code whenever it fails, instead of choosing once in the constructor. CryptoRandom.cpp already did that on every stir. The description has the test: the first commit aborts under a filter installed from bun:ffi followed by a forced reseed, and this one keeps running.

@dylan-conway
dylan-conway merged commit fb1167e into main Sep 30, 2026
48 checks passed

@claude claude Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I reviewed the head commit and didn't find any bugs; the #if defined(SYS_getrandom) guard added in f926720 covers the compile concern from the earlier round. Since this changes the process's OS entropy source in both WTF and bmalloc, a human look would still be worthwhile.

What was reviewed:

  • The WTF getrandom loop terminates on a 0 or non-EINTR failure and carries the partial count into the unchanged read() loop, so the fallback fills only the remainder.
  • The lazy openURandom() has a single caller (ARC4RandomNumberGenerator::stir() via OSRandomSource.cpp), which runs under m_lock, so the unsynchronized m_fd < 0 check is not racy in the tree as it stands.
  • In bmalloc, sizeof(randomness) and length are the same 128-byte array, so the <= 256 whole-or-fail assumption holds, and the /dev/urandom call_once path is byte-for-byte the previous code plus O_CLOEXEC.
  • SYS_getrandom is provided by glibc, musl and bionic <sys/syscall.h>; syscall comes from the already-included <unistd.h>.
Extended reasoning...

The diff touches Source/WTF/wtf/RandomDevice.cpp/.h and Source/bmalloc/bmalloc/CryptoRandom.cpp, replacing the eager /dev/urandom open on Linux with getrandom(2) via raw syscall and a lazy /dev/urandom fallback. This is the OS entropy source feeding WTF's and bmalloc's ARC4 generators (Math.random, crypto seeds, Gigacage randomization), which is security-sensitive crypto-adjacent code that the approval guidelines say not to auto-approve. Both directories are listed under jsc-reviewers in .github/CODEOWNERS. The earlier inline finding about unguarded SYS_getrandom was addressed by a later commit, the only RandomDevice caller is lock-serialized, and no new defects were found, so a defer with the reviewed areas is the right outcome rather than approve.

dylan-conway added a commit to oven-sh/bun that referenced this pull request Sep 30, 2026
…44281)

### What does this PR do?

Fixes a segfault at address `0x10` in `JSC::JSModuleLoader::loadModule`
← `moduleLoadTopSettled` (Sentry BUN-4NFS, BUN-41VH), on every platform.
The fix is in oven-sh/WebKit#748 (merged), which also explains why it
belongs there. This PR bumps WebKit to it and adds the tests.

Two things ride along:

- **The WebKit bump goes to `fb1167ebf2cb`, the current tip.** Besides
the fix (`0e8e9c238e91`) that brings in one more commit,
oven-sh/WebKit#749: on Linux, OS randomness is read with `getrandom(2)`,
and `/dev/urandom` is opened only when that fails.
- **A type error on main.** `src/js/bun/sql.ts(892,26): error TS2339:
Property 'command' does not exist on type '{}'` came in with #35119 and
fails "Lint JavaScript" for every PR that includes it.
`unsafeQueryFromTransaction()` now says what its query resolves to
(`SQLResultArray`). It is a type argument only, so the code that runs is
the same.

**Cause**

The module loader has two tables of what is loaded: the registry, and a
shortcut that lets a repeated `import()` skip resolving. Removing a
module clears both. But an `import()` of that module that is still in
flight adds it to the shortcut afterwards, when its dependencies have
loaded. The next `import()` finds the old module there, pairs it with a
new registry entry that has no load promise yet, and dereferences null.

```ts
const inFlight = import("./a"); // a.ts is registered, its dependencies are still loading
mock.module("./a", () => ({ a: "mocked-a" }));
await inFlight;
await import("./a"); // segfault
```

Everything that removes a module while its `import()` is in flight gets
there:

| route | 1.4.2 | canary `11c41c645` | with the fix |
|---|---|---|---|
| `delete require.cache[path]` | segfault | ok (#40123) | ok |
| `mock.module()` | segfault | segfault | ok |
| `build.module()` in a plugin | segfault | segfault | ok |
| a `bun --hot` reload | segfault | segfault | ok |
| a plugin's `onResolve` redirecting a → b → c → d, `delete
require.cache[d]`, `import(a)` again | segfault | segfault | ok |

The last one needs nothing in flight: the shortcut is keyed by the name
that was asked for, and removing a module cleared it only by the name it
resolved to. It was found in review, and nothing in the crash reports
points at it.

Of the 481 reports, 273 are from `bun test`, 203 from `bun run` or `bun
<file>`, and 240 had an HTTP server running.

**Fix** (oven-sh/WebKit#748)

The place that adds a module to the shortcut first checks that the
registry still holds it, and skips the write if not. And removing a
module clears it from the shortcut under whatever name it was asked for.
It is eleven lines in Bun's part of the loader. For a program that
removes no module nothing changes.

### How did you verify your code works?

Four new tests, one for each route that still crashes. All four fail on
1.4.2 and on canary `11c41c645`, and pass against oven-sh/WebKit#748 on
debug and release builds (Linux x64). Without the WebKit bump, CI had
all three segfault at `0x10` on every platform: macOS, Linux glibc and
musl, and Windows, x64 and arm64. The hot reload test passed 10 runs of
10.

| test | file |
|---|---|
| mock.module() of a module whose import() is still loading its
dependencies | `mock-module.test.ts` |
| build.module() of a module whose import() is still loading its
dependencies | `plugins.test.ts` |
| should import a module again after a hot reload while its import() was
still loading its dependencies | `hot.test.ts` |
| import() after delete require.cache of a module that onResolve
redirected a resolved path to | `plugins.test.ts` |

The WebKit PR has the evidence that nothing changes for a program that
removes no module, the comparison of behavior with main, WebKit's own
module tests and the cost.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant