Skip to content

repl: ignore non-regular history files and only read the tail of large ones - #38217

Open
robobun wants to merge 2 commits into
mainfrom
farm/5171eed1/repl-history-hostile-files
Open

repl: ignore non-regular history files and only read the tail of large ones#38217
robobun wants to merge 2 commits into
mainfrom
farm/5171eed1/repl-history-hostile-files

Conversation

@robobun

@robobun robobun commented Aug 13, 2026

Copy link
Copy Markdown
Collaborator

Problem

  • A FIFO at ~/.bun_repl_history hangs bun repl before it prints anything: History::load (src/runtime/cli/repl.rs) opens the file with a plain blocking open, which on a FIFO waits for a writer forever. The terminal is already in raw mode at that point, so Ctrl+C does nothing.
  • History::save opens the file the same way when the REPL exits, so a FIFO hangs the exit path too.
  • A huge history file is read into memory in full (File::read_from), and every line boxed, before the list is trimmed to the last 1000 entries. A 256 MiB file raises the debug build's peak RSS by 447 MiB; the report's 1.5 GB file got the process OOM-killed.
  • That trimming was while len > 1000 { entries.remove(0) }, quadratic in the number of lines in the file.

Fix

  • History::open_file is now the one place load and save open the history file. It adds O_NONBLOCK, so opening a FIFO returns immediately instead of waiting for a peer, and fstats the result, ignoring anything that is not a regular file. A FIFO, device or directory at the path is treated like a missing history file on both the read and the write side.
  • load reads at most the last MAX_HISTORY_FILE_BYTES (4 MiB) of the file with pread, collects entries from the end of that buffer, and stops after MAX_HISTORY_SIZE of them. When the read did not start at offset 0, the first line of the buffer is a cut-off fragment of an older entry and is dropped. A file exactly at the limit is still loaded whole.
  • Unchanged on purpose: the 0600 create mode and the tightening of an existing file's mode from Hardening: input validation and protocol tightening across 24 subsystems (round 7) #31495, and following a symlink to a regular file (the report also lists the symlink write-through; that is the Hardening: input validation and protocol tightening across 24 subsystems (round 7) #31495 behavior and node writes through the link as well). .load <file> and .save <file> are also untouched: they open a path the user typed and report errors, unlike the implicit startup read.
  • Tests: REPL history file loading in test/js/bun/repl/repl.test.ts. Before the fix, the FIFO test hung until the test timeout, the 256 MiB sparse file grew RSS by 447 MiB (about 5 MiB now), and the file one byte over the limit kept its cut-off first line. A file exactly at the limit and a 1200-entry file pin down that nothing else about loading changed.
  • The whole repl.test.ts file passes (153 tests); cargo check -p bun_runtime passes for x86_64-pc-windows-msvc and aarch64-apple-darwin.

Background

  • bun repl keeps the lines typed into it in ~/.bun_repl_history, one entry per line, newest last. History::load reads it after the terminal is switched to raw mode and before the banner is printed; History::save rewrites it with the last 1000 entries on exit. Both are silent best-effort operations, so any failure just means no history.
  • open(2) on a FIFO blocks until the other end is opened too. With O_NONBLOCK, a read-only open succeeds immediately and a write-only open with no reader fails with ENXIO; on a regular file the flag does nothing, so it can be passed unconditionally.
  • Because save only ever keeps the newest 1000 entries, reading just the tail of the file and walking it backwards drops nothing that would have survived the next save anyway (other than entries past the byte limit).

…e ones

History::load opened ~/.bun_repl_history with a plain blocking open and read
the whole file, so a FIFO at that path hung `bun repl` before it printed
anything (and History::save hung it again at exit), and a huge file was read
into memory in full before being trimmed to the last 1000 entries.

Both now go through History::open_file, which opens with O_NONBLOCK and
skips anything that is not a regular file. load reads at most the last
MAX_HISTORY_FILE_BYTES of the file and collects entries from the end, which
also replaces the quadratic remove(0) trimming loop.
@coderabbitai

coderabbitai Bot commented Aug 13, 2026

Copy link
Copy Markdown
Contributor

Warning

Review limit reached

@robobun, you've reached your PR review limit, so we couldn't start this review.

Next review available in: 27 minutes

Enable usage-based reviews in Billing to review now. Otherwise, wait until the next included review is available.
You're only billed for reviews past your plan's rate limits ($0.25/file).

How can I continue?

After more reviews become available, a review can be triggered using the @coderabbitai review command as a PR comment. Alternatively, push new commits to this PR.

To avoid repeated limits, reduce automatic review volume by pausing incremental auto-reviews earlier, using label-based review opt-in, excluding WIP or generated PR titles, or requesting reviews manually when the PR is ready. If your team needs uninterrupted high-volume reviews, an organization admin can enable usage-based reviews.

How do review limits work?

CodeRabbit enforces per-developer PR review limits for each organization. Most developers receive the normal plan review availability.

For paid Pro and Pro+ PR reviews, CodeRabbit uses adaptive limits for sustained high-volume activity. When a developer's recent PR review activity reaches the 95th percentile or higher among CodeRabbit users, additional reviews become available more gradually as earlier reviews age out of the rolling window.

Please refer docs for additional details.

Review details
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: ASSERTIVE

Plan: Pro

Run ID: 77b6d55e-a1eb-4815-84bd-1c5da98e055f

📥 Commits

Reviewing files that changed from the base of the PR and between b5afcac and 86ebf2c.

📒 Files selected for processing (2)
  • src/runtime/cli/repl.rs
  • test/js/bun/repl/repl.test.ts

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

@robobun

robobun commented Aug 13, 2026

Copy link
Copy Markdown
Collaborator Author
Updated 4:24 PM PT - Aug 13th, 2026

@robobun, your commit 86ebf2c has 2 failures in Build #94860 (All Failures):


🧪   To try this PR locally:

bunx bun-pr 38217

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

bun-38217 --bun

@robobun

robobun commented Aug 13, 2026

Copy link
Copy Markdown
Collaborator Author

Status: fix and tests pushed, waiting for CI.

Reproduced on a debug build of main (b7a0431) with HOME pointing at a scratch directory:

  • mkfifo $HOME/.bun_repl_history; bun repl prints nothing and never returns (killed after 20s); with the fix it starts, evaluates 1 + 1, and exits 0 with the FIFO left in place.
  • A 256 MiB sparse .bun_repl_history raised the REPL's peak RSS from 302 MiB to 802 MiB; with the fix it is within 5 MiB of the empty-history baseline.
  • The new tests in test/js/bun/repl/repl.test.ts fail the same three ways (timeout, +447 MiB, cut-off line kept) when src/ is reverted and pass with it.

Comment thread src/runtime/cli/repl.rs
Comment on lines +244 to +251
fn open_file(path: &[u8], flags: i32) -> Option<sys::File> {
let flags = flags | sys::O::NONBLOCK | sys::O::NOCTTY | sys::O::CLOEXEC;
let file = sys::File::openat(Fd::cwd(), path, flags, 0o600).ok()?;
match file.kind() {
Ok(sys::FileKind::File) => Some(file),
_ => None,
}
}

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.

🔴 On Windows, sys::O::NONBLOCK passed to File::openat makes openat_windows_impl drop FILE_SYNCHRONOUS_IO_NONALERT from NtCreateFile, so open_file now returns an asynchronous HANDLE — but save()'s write_all calls WriteFile with lpOverlapped=NULL and load()'s pread_all passes a stack-local OVERLAPPED whose SAFETY comment requires a synchronous handle. Before this PR both paths opened synchronous handles, so this regresses Windows history persistence (and the three new non-skipIf(isWindows) tests round-trip through it). Gate the NONBLOCK | NOCTTY OR-in behind #[cfg(not(windows))] — see DirectoryRoute.rs:319-326 for the same pattern and rationale.

Extended reasoning...

What the bug is

History::open_file unconditionally ORs sys::O::NONBLOCK into the open flags for both the load and save paths:

fn open_file(path: &[u8], flags: i32) -> Option<sys::File> {
    let flags = flags | sys::O::NONBLOCK | sys::O::NOCTTY | sys::O::CLOEXEC;
    let file = sys::File::openat(Fd::cwd(), path, flags, 0o600).ok()?;
    ...
}

The doc comment says "the flag has no effect on the regular file this accepts", which is true on POSIX but false in bun_sys on Windows. On Windows, sys::O::NONBLOCK is 0o4000 (src/sys/lib.rs:1180), and File::openatopenat_aopenat (Windows arm, lib.rs:3969) → openat_windows_aopenat_windows_impl (lib.rs:7137). At line 7153 that function reads let nonblock = (flags & O::NONBLOCK) != 0, and at lines 7182-7186 it does:

let blocking_flag: u32 = if !nonblock {
    w::FILE_SYNCHRONOUS_IO_NONALERT
} else {
    0
};

So passing O::NONBLOCK drops FILE_SYNCHRONOUS_IO_NONALERT from the NtCreateFile options and yields an asynchronous (overlapped) HANDLE.

Why the downstream I/O breaks

Both callers assume a synchronous handle:

  • save()file.write_all()sys::write() (src/sys/lib.rs:3652-3680) calls kernel32::WriteFile with lpOverlapped = null_mut(). Microsoft documents that on a handle opened for asynchronous I/O the caller must supply an OVERLAPPED; passing NULL on such a handle is invalid and in practice fails (there is no I/O-manager-maintained file position). History is therefore silently never saved on Windows.
  • load()file.pread_all()sys::pread() (src/sys/lib.rs:3682-3722) constructs a stack-local OVERLAPPED with hEvent = null and passes it to ReadFile. Its own // SAFETY: comment (line 3702) states "overlapped lives for the synchronous call (handle was not opened FILE_FLAG_OVERLAPPED)", and the error match (lines 3712-3719) handles BROKEN_PIPE/HANDLE_EOF/OPERATION_ABORTED but not ERROR_IO_PENDING. On an asynchronous handle ReadFile may return FALSE + ERROR_IO_PENDING; pread then returns Err and unwinds while the kernel still holds a pointer to the now-dead stack OVERLAPPED — a documented-SAFETY-invariant violation and a stack use-after-free.

Step-by-step proof (Windows)

  1. User launches bun repl on Windows with a .bun_repl_history file present.
  2. History::load calls Self::open_file(path, sys::O::RDONLY).
  3. open_file computes flags = O::RDONLY | O::NONBLOCK | 0 | O::CLOEXEC (O::NOCTTY is 0 on Windows, lib.rs:1228).
  4. File::openat reaches openat_windows_impl; nonblock evaluates true; blocking_flag becomes 0; NtCreateFile is called without FILE_SYNCHRONOUS_IO_NONALERT → the returned HANDLE is asynchronous.
  5. file.pread_all(&mut content, offset) calls sys::pread, which passes the address of a stack OVERLAPPED to ReadFile on that asynchronous handle. Either the read fails immediately (history not loaded) or it returns ERROR_IO_PENDING, in which case pread returns Err and the kernel later writes into freed stack.
  6. On exit, History::save calls Self::open_file(path, O::WRONLY | O::CREAT | O::TRUNC) → same asynchronous handle → WriteFile(..., NULL) fails → history not saved.

Why this is a regression introduced by the PR

Before this PR, save() used sys::open_a(path, O::WRONLY | O::CREAT | O::TRUNC, 0o600) (no NONBLOCK) and load() used sys::File::read_from — both produced synchronous handles on Windows. The PR description states only cargo check -p bun_runtime --target x86_64-pc-windows-msvc was run, not the tests. Three of the new tests — "keeps the newest entries…", "loads a file exactly at the size limit…", "loads only the tail of a file over the size limit" — are not gated by isWindows, set USERPROFILE via homeEnv(), and assert the exact contents of the saved history file, so a broken save() fails Windows CI.

The codebase already documents this hazard

src/runtime/server/DirectoryRoute.rs:319-326 has the exact pattern with a comment explaining why:

// NONBLOCK so opening a FIFO without a writer cannot block the event
// loop on POSIX. Not on Windows: there `openat` maps it to omitting
// FILE_SYNCHRONOUS_IO_NONALERT, which breaks the synchronous reads
// FileResponseStream issues.
#[cfg(not(windows))]
let flags = bun_sys::O::RDONLY | bun_sys::O::CLOEXEC | bun_sys::O::NONBLOCK;
#[cfg(windows)]
let flags = bun_sys::O::RDONLY | bun_sys::O::CLOEXEC;

The unconditional-NONBLOCK sites elsewhere in the tree (Blob.rs:1481, FileReader.rs:206) all go through bun_sys::open(), which on Windows routes to sys_uv::open — a different codepath that does not hit NtCreateFile.

Fix

Gate the POSIX-only flags on cfg:

fn open_file(path: &[u8], flags: i32) -> Option<sys::File> {
    // O_NONBLOCK so opening a FIFO returns immediately on POSIX. Not on
    // Windows: File::openat maps it to omitting FILE_SYNCHRONOUS_IO_NONALERT,
    // which breaks the synchronous pread/write below (and Windows has no
    // filesystem FIFOs anyway).
    #[cfg(not(windows))]
    let flags = flags | sys::O::NONBLOCK | sys::O::NOCTTY | sys::O::CLOEXEC;
    #[cfg(windows)]
    let flags = flags | sys::O::CLOEXEC;
    let file = sys::File::openat(Fd::cwd(), path, flags, 0o600).ok()?;
    ...
}

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