Skip to content

Surface ENOMEM instead of aborting when a whole-file read cannot allocate st_size - #33178

Merged
Jarred-Sumner merged 2 commits into
mainfrom
farm/575a99fd/env-sparse-read-oom
Jul 1, 2026
Merged

Jarred-Sumner merged 2 commits into
mainfrom
farm/575a99fd/env-sparse-read-oom

Conversation

@robobun

@robobun robobun commented Jul 1, 2026

Copy link
Copy Markdown
Collaborator

Problem

bun_sys::File::read_to_end presizes its destination with list.reserve_exact(get_end_pos()? + 16) (src/sys/file.rs:217), where get_end_pos() is a fresh fstat. st_size is only a hint: sparse files, files truncated or grown by a racing writer, and unusual filesystems can report sizes that have nothing to do with what is readable or allocatable. reserve_exact is infallible, so when the allocator refuses the request, Rust's handle_alloc_error aborts the whole process. No JS code can catch it.

The cheapest reproduction is the automatic .env loader, which runs before any user code:

truncate -s 1T .env   # sparse: 0 bytes of real data
bun app.js
# memory allocation of 1099511627792 bytes failed
# SIGABRT (exit 134), app.js never runs

1099511627792 is exactly st_size + 16. Verified on bun 1.4.0 (release, under an 8 GB address space limit) and on a debug ASAN build (ASAN caps a single request at 1 TiB). Without a memory limit the release build instead commits the lying size and reads sparse zeros until the OOM killer ends it, still before user code.

The .env loader has an explicit branch that treats ENOMEM while reading an env file as recoverable (src/dotenv/env_loader.rs, read_env_file_contents), but it was unreachable: the abort happened in the reservation, before read() could return anything. The same read_to_end is reached from bunx's package.json read, bun pack, the lockfile reader, the standalone module graph, and Bun.Image(path), where a second fstat inside read_to_end can also disagree with the st_size the caller just validated against MAX_INPUT_FILE_BYTES.

Fix

Treat the fstat-derived size as a hint and make every reservation in the read path fallible:

  • read_to_end_with_array_list: try_reserve_exact the saturating st_size + 16 hint and return the existing bun_sys::Error::oom() (ENOMEM) on failure.
  • read_fill_vec: the incremental reserve(grow_by) in the read loop becomes try_reserve with the same error.

No signatures change and every caller already handles Err from these functions (the fstat itself could fail before). With the fix, the .env case takes the already-designed recoverable path and startup reaches user code; the other callers report a normal ENOMEM error.

Files whose content really is that large still cost that much memory to read, same as node --env-file; this change only guarantees that allocation failure surfaces as ENOMEM instead of SIGABRT.

Tests

test/cli/run/env.test.ts:

  • New: .env with a huge lying st_size does not abort the process. Creates a 1 TiB sparse .env, runs bun app.js, asserts user code runs and the process exits 0. Gated to ASAN builds (skipIf(!isASAN || isWindows)): ASAN deterministically refuses the 1 TiB request (allocator_may_return_null=1), so the test does not depend on machine memory. On the unfixed build the child dies with exit 134 and empty stdout; on the fixed build it prints reached user code and exits 0. A release build would treat the sparse file as a genuinely huge one, so there is nothing deterministic to assert there.
  • Existing .env with 50000 entries now has an explicit debug-build timeout. The spawned debug ASAN child alone takes about 8 seconds on a slower machine (measured the same, 8.2s, with and without this change), which cannot fit the default 5s budget. Workload and assertions are unchanged.
fail before / pass after
# src stashed (unfixed), bun bd test test/cli/run/env.test.ts -t "huge lying"
  {
-   "exitCode": 0,
-   "stdout": "reached user code\n",
+   "exitCode": 134,
+   "stdout": "",
  }
(fail) .env with a huge lying st_size does not abort the process

# with the fix
 81 pass, 0 fail  (full test/cli/run/env.test.ts)

File::read_to_end presized its destination with reserve_exact on a size
taken from a fresh fstat. st_size is only a hint (sparse or truncated
files, writers racing the read), so an oversized value made the
infallible reservation call handle_alloc_error and abort the process
with SIGABRT instead of returning an error.

The cheapest reproduction is the automatic .env loader, which runs
before any user code:

    truncate -s 1T .env
    bun app.js
    # memory allocation of 1099511627792 bytes failed
    # SIGABRT before app.js runs

The env loader already treats ENOMEM while reading an env file as
recoverable, but that branch was unreachable because the reservation
aborted before read() could return.

Use try_reserve/try_reserve_exact and surface failures as ENOMEM via the
existing bun_sys::Error::oom(), for both the fstat-derived presize and
the incremental growth in read_fill_vec. Every consumer of read_to_end
(env loader, bunx, bun pack, lockfile, Bun.Image, standalone graph) now
gets a regular error instead of an abort.

The ".env with 50000 entries" test also gets an explicit debug-build
timeout: the spawned debug ASAN child alone needs ~8s on slower
machines, independent of this change.
@github-actions github-actions Bot added the claude label Jul 1, 2026
@robobun

robobun commented Jul 1, 2026 •

Copy link
Copy Markdown
Collaborator Author
Updated 7:05 AM PT - Jul 1st, 2026

❌ @robobun, your commit 380a123 has 1 failures in Build #67563 (All Failures):


🧪   To try this PR locally:

bunx bun-pr 33178

That installs a local version of the PR into your bun-33178 executable, so you can run:

bun-33178 --bun

@coderabbitai

coderabbitai Bot commented Jul 1, 2026

Copy link
Copy Markdown
Contributor

Review Change Stack

Walkthrough

File reading reservation logic in file.rs and lib.rs was changed from panicking allocation calls to fallible try_reserve/try_reserve_exact calls returning Error::oom() on failure. Corresponding env.test.ts tests were updated with timeout adjustments and a new ASAN-only oversized-file regression test.

Changes

Fallible allocation handling

Layer / File(s) Summary
Fallible reservation in read paths
src/sys/file.rs, src/sys/lib.rs
read_to_end_with_array_list and read_fill_vec now use try_reserve/try_reserve_exact and return Error::oom() on failure instead of panicking allocation calls.
Env loader tests
test/cli/run/env.test.ts
Imports reformatted to multi-line, 50000-entry test given a conditional timeout for debug builds, and a new ASAN-only test added verifying oversized sparse .env files with lying st_size do not abort the process.

Sequence Diagram(s)

sequenceDiagram
  participant Test as env.test.ts
  participant Bun as bun process
  participant Loader as env loader
  Test->>Bun: spawn with oversized sparse .env
  Bun->>Loader: read_to_end_with_array_list
  Loader->>Loader: try_reserve_exact(want)
  Loader-->>Bun: Err(Error::oom()) if allocation fails
  Bun-->>Test: reaches user code, exit code 0
Loading

Compact metadata

  • Estimated review effort: Medium
  • Lines changed: +80/-15

Related issues: None referenced in provided data.

Related PRs: None referenced in provided data.

Suggested labels: rust, tests, memory-safety

Suggested reviewers: None specified in provided data.

Poem
A vec that once would panic and crash,
now tries its reserve before making a splash.
Sparse files grow huge, size lying and wide,
yet bun keeps its calm and slips safely inside.
Tests stand watch under ASAN's keen eye,
OOM turns to error, not a crash in the sky.

🚥 Pre-merge checks | ✅ 4
✅ Passed checks (4 passed)
Check name Status Explanation
Title check ✅ Passed The title clearly summarizes the main change: surfacing ENOMEM from whole-file reads instead of aborting.
Description check ✅ Passed The description covers the problem, fix, and verification, which satisfies the template's intent despite different headings.
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.

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

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Actionable comments posted: 1

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@test/cli/run/env.test.ts`:
- Around line 341-364: The .env stress test in env.test.ts is avoiding the
preferred fix by increasing the per-test timeout; instead, reduce the workload
in the .env with 50000 entries case so it finishes within the normal budget.
Update the large-entry setup in the test callback (and any related expected loop
in index.ts) to use fewer entries while still validating parsing, rather than
relying on the isDebug timeout branch.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: ASSERTIVE

Plan: Pro

Run ID: d89d9d0a-537a-4529-8835-1014b1b13cee

📥 Commits

Reviewing files that changed from the base of the PR and between d816daf and e2e732e.

📒 Files selected for processing (3)
  • src/sys/file.rs
  • src/sys/lib.rs
  • test/cli/run/env.test.ts

Comment thread test/cli/run/env.test.ts

@claude claude Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

LGTM — mechanical swap from infallible reserve* to try_reserve* so allocation failure surfaces as ENOMEM instead of aborting; no signature or success-path changes.

Extended reasoning...

Overview

This PR touches three files: src/sys/file.rs (the read_to_end_with_array_list reservation), src/sys/lib.rs (the shared read_fill_vec growth loop), and test/cli/run/env.test.ts (one new ASAN-gated regression test plus a debug-build timeout on an existing slow test). The native change is a mechanical swap of Vec::reserve / reserve_exact for try_reserve / try_reserve_exact, returning the crate's existing bun_sys::Error::oom() on failure. The + 16 on the fstat hint also becomes .saturating_add(16), which is strictly safer.

Security risks

None introduced. If anything this is a hardening change: an attacker-controlled sparse file or racing writer that inflates st_size can no longer abort the process via handle_alloc_error before user code runs — it now surfaces as a catchable ENOMEM through paths that already handle Err. No new inputs are trusted, no auth/crypto/permissions touched.

Level of scrutiny

Low-to-moderate. read_fill_vec and read_to_end_with_array_list are on a widely-used file-read path, but the change does not alter control flow, signatures, or success-path semantics — try_reserve* behaves identically to reserve* when allocation succeeds. Every caller already handled Maybe<T> failure (the pre-existing get_end_pos()? could already fail), so the new error case flows through existing plumbing. The .env loader's ENOMEM branch that this now makes reachable was already written for exactly this case.

Other factors

The new test follows repo conventions (bunEnv spread, concurrent pipe draining, await using, {stdout, exitCode} object assertion, no stderr-empty check) and is deterministically gated to ASAN via allocator_may_return_null=1 so it doesn't depend on host memory. The added timeout on .env with 50000 entries is a benign accommodation for debug+ASAN slowness with an explanatory comment; workload and assertions are unchanged. No CODEOWNERS cover these paths, no bugs were flagged by the bug-hunting pass, and no prior reviewer comments are outstanding.

@robobun

robobun commented Jul 1, 2026 •

Copy link
Copy Markdown
Collaborator Author

CI status, final for both runs of this PR. The change is green on every lane that ran it; the remaining failures are infrastructure or a known pre-existing flake, with evidence below.

  • Build 67543: darwin 26 aarch64 - test-bun failed with buildkite-agent artifact download timed out after 120s before running any tests; the alpine aarch64 lane could not start its MySQL container (application not healthy after 1m0s); the two linux-x64-musl lanes failed test/js/node/test/parallel/test-net-connect-memleak.js.
  • Build 67563, re-run from an empty commit: the same darwin agent (darwin-aarch64-26.5.1-1) timed out on the same artifact download again, still before running any tests, and the two linux-x64-musl lanes failed the same test-net-connect-memleak.js. The MySQL lane passed this time. Everything else is green.

test-net-connect-memleak.js on linux-x64-musl is the pre-existing flake tracked in #33044 (failing on about half of PR builds on that platform since June 28, before this PR existed) with quarantine PR #33045 open. It asserts that a dropped socket is collected right after globalThis.gc() and only fails on the musl x64 lanes; this PR does not touch sockets or GC, and the x64-asan, glibc x64, aarch64, darwin, and Windows lanes all pass it.

Nothing left to act on from my side: the darwin job needs a retry by someone with Buildkite access (the agent fails to download the build artifact regardless of the diff), and the musl failures are #33044. I am not pushing further empty commits to retrigger the pipeline.

@claude claude Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

The change looks correct and well-tested to me, but since read_to_end / read_fill_vec are the shared whole-file read primitives reached from the env loader, bunx, lockfile reader, standalone module graph, and more, it's worth a maintainer's eyes before landing.

Extended reasoning...

Overview

This PR swaps infallible Vec::reserve / reserve_exact calls for their fallible try_reserve / try_reserve_exact counterparts in src/sys/file.rs::read_to_end_with_array_list and src/sys/lib.rs::read_fill_vec, returning the existing bun_sys::Error::oom() (ENOMEM) on failure instead of aborting the process via Rust's handle_alloc_error. It also replaces get_end_pos()? + 16 with saturating_add(16) for defensive correctness. A new ASAN-gated test creates a 1 TiB sparse .env and asserts user code is reached; an existing 50000-entry test gains a debug-only timeout.

Security risks

None identified. The change strictly reduces attack surface: an untrusted st_size (sparse file, racing writer, hostile filesystem) can no longer abort the process before user code runs. No new inputs are accepted and no validation is loosened.

Level of scrutiny

Moderate-to-high. The diff itself is mechanically simple — try_reserve has identical success-path semantics to reserve, and every caller already handles Err from these functions (the fstat inside could always fail). However, bun_sys::File::read_to_end is a foundational primitive with a wide blast radius (env loader, bunx, bun pack, lockfile reader, standalone module graph, Bun.Image). Changes at this layer warrant a maintainer sign-off even when they look obviously correct.

Other factors

  • No bugs surfaced by the bug-hunting system.
  • The one CodeRabbit nit (about the timeout bump) was discussed and withdrawn as resolved.
  • The PR description is thorough with a reproducible fail-before/pass-after demonstration.
  • No CODEOWNERS entry covers src/sys/.
  • The change is at the current HEAD (e2e732e8 sys: make whole-file read reservations fallible), suggesting it may already be integrated.

@Jarred-Sumner
Jarred-Sumner merged commit 3128951 into main Jul 1, 2026
77 of 78 checks passed
@Jarred-Sumner
Jarred-Sumner deleted the farm/575a99fd/env-sparse-read-oom branch July 1, 2026 19:21
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants