Skip to content

repl: keep multi-line history entries on one line of the history file - #38023

Open
robobun wants to merge 6 commits into
mainfrom
farm/17dfb100/repl-history-multiline-entries
Open

repl: keep multi-line history entries on one line of the history file#38023
robobun wants to merge 6 commits into
mainfrom
farm/17dfb100/repl-history-multiline-entries

Conversation

@robobun

@robobun robobun commented Aug 13, 2026

Copy link
Copy Markdown
Collaborator

Problem

  • bun repl keeps a multi-line input (a function typed over three lines, say) as one history entry, but History::save in src/runtime/cli/repl.rs writes every entry verbatim followed by \n, so that entry lands in ~/.bun_repl_history as three physical lines.
  • History::load splits the file on \n, so the next session gets three entries: function f() {, return 1, }. Arrow-up recalls a fragment (a syntax error, or it drops into multi-line mode) and .history lists the fragments. Within the session that typed it, the same input is one entry, so only the file round trip is wrong.
  • The same \n-only split keeps the \r of a CRLF line ending (a history file written on Windows or re-saved by a CRLF editor) on every loaded entry, so .history prints old_one\r, recall inserts the CR, and the next save writes it back (repl: strip trailing CR when loading history (Windows CRLF) #30293, previously carried by repl: strip trailing CR when loading history (Windows CRLF) #36510).
  • Repro on bun 1.4.0: printf 'function f() {\n return 1\n}\n.exit\n' | HOME=/tmp/h bun repl, then .history in a second HOME=/tmp/h bun repl lists 3 entries. For the CRLF case: printf 'old_one\r\nold_two\r\n' > /tmp/h/.bun_repl_history, then .history in HOME=/tmp/h bun repl prints 1 old_one\r.

Fix

  • save writes the newlines inside an entry as HISTORY_STORED_NEWLINE (\r), so each entry is exactly one line of the file. load drops a CRLF line ending and turns the remaining \rs back into \n. Both directions are strings::replace_owned.
  • A typed \r never reaches an entry: the line editor reads it as Enter (Key::from_byte), so using it as the stored form of a newline does not change the meaning of any typed entry. The one way a CR can get into an entry is tab-completing a property whose name contains one; that entry comes back with \n in its place, which JS treats as a line terminator just like CR, so it still evaluates the same. This is the encoding node's repl history uses, with the same edge (internal/repl/history.js swaps \n and \r when storing an entry and keeps one entry per line of .node_repl_history).
  • Entries are added with their surrounding newlines trimmed (handle_enter), so a stored entry never ends in \r; stripping one trailing \r per line is therefore unambiguous. It is also what keeps a CRLF-converted file loading correctly, since otherwise the line ending would now decode into a trailing newline on every entry. Node's loader likewise splits its history file on /\r?\n+/.
  • Files written by bun so far contain no \r, so they load exactly as before (an entry an older bun already split stays split); CRLF-converted files are the intended change above. A file written by this version still loads in an older bun as one entry per line, with the CRs of a multi-line entry shown raw; the entry still evaluates, since CR is a line terminator.
  • In-memory entries keep real newlines, so .history, recall, the last-entry duplicate check and .save (which writes source code, where the newlines belong) are unchanged.
  • Verified with the REPL history file block in test/js/bun/repl/repl.test.ts: the file holds the multi-line entry on one line and the next session lists it as one entry, recalls it with arrow-up and runs it; entry order and blank lines inside an entry survive the round trip; a CRLF file containing a stored multi-line entry loads without stray \rs and is re-saved with LF endings; a CRLF file of ordinary single-line entries (the repl: strip trailing CR when loading history (Windows CRLF) #30293 case) does the same. All four fail on bun 1.4.0 and pass with this change.
  • The whole file passes on a debug build (152 tests); cargo fmt --check and cargo clippy -p bun_runtime are clean.

Relation to #36510 and #30293

Background

  • A history entry is the text of one evaluated input. In multi-line mode the REPL accumulates the typed lines in multiline_buffer and, once the code parses, adds the whole buffer (embedded newlines included) as a single entry.
  • The history file is $HOME/.bun_repl_history (USERPROFILE on Windows). It is read once at startup and rewritten from the in-memory entries on exit (only if an entry was added during the session); its only structure is one entry per line, which is why an entry's own newlines need a different in-line byte. Bun itself always writes LF; CRLF endings only come from an external editor.
  • Issue bun repl: code repeatly printed when deleting multi-line history code #27560 (the terminal redraw of a recalled multi-line entry) is a display bug in refresh_line and is not touched here; this change only affects what is written to and read from the file. Until it is fixed, recalling a multi-line entry from a previous session redraws the same way recalling one from the current session already does.

no test proof · iteration 0 · Platform-specific test(s) that do not run on this machine. Deferring to CI, which covers all platforms: test/js/bun/repl/repl.test.ts

History::save wrote each entry verbatim followed by '\n', so an entry
that was typed over several lines occupied several lines of
~/.bun_repl_history, and History::load (which splits on '\n') brought it
back as one entry per fragment in the next session.

Store the newlines inside an entry as '\r' on save and turn them back on
load, the encoding node's repl history uses. The line editor reads a
typed '\r' as Enter, so no entry contains one and the decode cannot alter
a real entry. load strips a CRLF line ending before decoding, so a file
rewritten with CRLF endings does not gain a trailing newline per entry.
In-memory entries are unchanged, so .history, recall and .save behave as
before.

Co-authored-by: Samuel <samuelpatro@users.noreply.github.com>
@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: 17 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: 42be1e48-3143-48cf-94cf-d62a8c2c0d4f

📥 Commits

Reviewing files that changed from the base of the PR and between bdb7382 and 718d951.

📒 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:34 PM PT - Aug 13th, 2026

@robobun, your commit 718d951f103c0f22dada99282da6de565429d030 passed in Build #94868! 🎉


🧪   To try this PR locally:

bunx bun-pr 38023

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

bun-38023 --bun

@robobun

robobun commented Aug 13, 2026

Copy link
Copy Markdown
Collaborator Author

Status: reproduced on bun 1.4.0 by piping function f() { / return 1 / } / .exit into HOME=<tmp> bun repl: the history file came out as three lines and .history in a second session with the same HOME listed three entries. Fixed in this PR; the four tests in the REPL history file block of test/js/bun/repl/repl.test.ts (the last one is the CRLF case from #30293 / #36510, which is now closed in favour of this PR) fail on 1.4.0 and pass on a debug build of this branch, and the whole file passes (152 tests).

@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 — small, self-contained fix that swaps \n\r in history entries so multi-line inputs survive a restart as one entry.

What was reviewed

  • append_swapping_line_breaks + the load/save changes: verified the split/join is a plain byte replace and trim_suffix strips exactly one trailing \r.
  • Checked the invariants the encoding rests on: Key::from_byte maps 13 to Enter so no entry contains \r, and handle_enter trims surrounding \n before history.add so no encoded entry ends in \r (CRLF strip is unambiguous).
  • Confirmed .save (cmd_save) is intentionally unchanged — it writes runnable source, not the history-file encoding.
  • Tests: HOME/USERPROFILE both set (env_var::HOME reads USERPROFILE on Windows), exact file-content and snapshot assertions cover round-trip, blank lines inside an entry, and CRLF re-save.
Extended reasoning...

Overview

Two files: src/runtime/cli/repl.rs (adds a 10-line append_swapping_line_breaks helper and rewires History::load/History::save through it — ~25 changed lines) and test/js/bun/repl/repl.test.ts (a new describe.concurrent("REPL history file") block with three tests). The fix stores each newline inside a multi-line history entry as \r on disk and decodes it back on load, so ~/.bun_repl_history keeps one entry per physical line and a restarted session recalls the whole multi-line input instead of fragments. This matches Node's .node_repl_history encoding.

Security risks

None. This is developer-tool persistence of the REPL's own input history to a file the REPL itself writes; no untrusted input is parsed differently, no auth/crypto/permissions code is touched, and the file mode (0600) path is unchanged.

Level of scrutiny

Low-to-moderate. The change is a byte-level string transform in a pair of symmetric functions with a small, easy-to-audit helper. I verified the two invariants the scheme depends on directly in the surrounding code: (1) \r cannot appear in an in-memory entry because Key::from_byte maps byte 13 to Key::Enter, and multi-byte UTF-8 never encodes \r; (2) entries are trimmed of surrounding \n before history.add, so an encoded entry never ends in \r and the CRLF trim_suffix cannot eat a real trailing newline. strings::split/strings::trim_suffix were checked in bun_core. The .save REPL command is deliberately left writing real newlines (it emits runnable JS, not the history encoding), which the PR description calls out.

Other factors

  • Backward compatible: existing history files contain no \r (other than CRLF endings, which are now stripped), so they load unchanged.
  • Tests are strong per the repo guidelines: exact toEqual on the saved file's physical lines, inline snapshots of the second session's output (arrow-up recall actually defines f and f() returns 1), an explicit not.toContain("\r") guard because snapshots tolerate CRLF, and a CRLF-file re-save assertion. HOME and USERPROFILE are both set and env_var::HOME is platform-specific, so the block is not POSIX-only.
  • The PR notes a one-line overlap with #36510 in load; that is a merge-order concern, not a correctness issue here.
  • No prior reviews or outstanding comments on this PR.

The local split-and-copy helper duplicated strings::replace_owned; both
history sites now call that directly. The encoding and the invariants it
relies on are documented once, at the save site, and the CRLF comments no
longer attribute CRLF files to Windows (bun itself never writes CRLF to
the history file; only an external editor does).
Comment thread src/runtime/cli/repl.rs Outdated
Comment thread src/runtime/cli/repl.rs Outdated
Both history sites now read the byte from HISTORY_STORED_NEWLINE, whose
doc comment is the one place the file encoding is described; the inline
comments at the save and load sites are gone, except a one-liner on the
CRLF trim saying why it cannot eat entry content.
Comment thread src/runtime/cli/repl.rs Outdated

@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 — small, well-reasoned fix that mirrors Node's .node_repl_history encoding.

What was reviewed:

  • History::save/load round-trip: verified replace_owned and trim_suffix semantics; entries never end in \n (trimmed in handle_enter), so the CRLF strip is unambiguous.
  • Confirmed \r can't otherwise reach an entry (Key::from_byte maps 13 → Enter; editor mode and .load don't call history.add).
  • cmd_save intentionally left writing real newlines (it emits source, not the history-file encoding).
  • Tests cover the on-disk format, arrow-up recall + duplicate suppression, blank lines inside an entry, entry order, and CRLF load/re-save.
Extended reasoning...

Overview

Two files: ~10 changed lines in src/runtime/cli/repl.rs (History::load/save plus a new HISTORY_STORED_NEWLINE constant) and a three-test describe.concurrent block in test/js/bun/repl/repl.test.ts. The fix encodes newlines inside a history entry as \r on save and decodes them on load, so a multi-line REPL input survives a restart as one entry instead of fragmenting into one entry per physical line.

Security risks

None. This is encoding of a per-user history file the REPL already reads and writes; no new paths, no external input beyond what load() already handled, and the file mode (0600) is unchanged.

Level of scrutiny

Low-to-medium. The change is mechanical (two strings::replace_owned calls and a trim_suffix), scoped to the REPL's history file, and follows Node's internal/repl/history.js encoding exactly. The one subtlety — whether stripping a trailing \r per line can ever eat entry content — is closed by the invariant that handle_enter trims surrounding newlines before history.add(), so a stored entry never ends in \r. I traced every path that reaches history.add() (only handle_enter; editor mode and .load evaluate without adding) and confirmed Key::from_byte maps CR to Enter, so \r cannot appear as entry content.

Other factors

The comment-cop bot flagged over-long comments on earlier commits; those were resolved in aa5d213 (the encoding is now a one-line doc comment on the constant), and all inline threads are marked resolved. cmd_save (the .save REPL command) is deliberately left writing real newlines since it emits runnable source, not the history-file format — the PR description calls this out. The new tests are hermetic (tempDir, per-test HOME/USERPROFILE), concurrent, and assert the exact on-disk bytes plus a full-session snapshot including arrow-up recall, duplicate suppression, blank-line preservation, entry order, and CRLF→LF re-save. The PR notes it supersedes #36510 (CRLF-only) and documents the trivial rebase if that lands first.

@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 — small, well-scoped fix that mirrors Node's .node_repl_history encoding.

What was reviewed

  • History::save/load round-trip: \r cannot enter an entry via the line editor (Key::from_byte maps 13 → Enter) and add() gets \n-trimmed input, so the trailing-\r strip is unambiguous vs. CRLF endings.
  • Confirmed strings::replace_owned / strings::trim_suffix signatures match usage; env_var::HOME reads USERPROFILE on Windows so the new tests (which set both) are cross-platform.
  • cmd_save intentionally left writing raw newlines (it emits source code, not the history-file format); comment-cop feedback on long comments was addressed in aa5d213.
Extended reasoning...

Overview

This PR fixes multi-line REPL history persistence in src/runtime/cli/repl.rs. History::save now replaces embedded \n with \r (the new HISTORY_STORED_NEWLINE constant) so each entry occupies exactly one line of ~/.bun_repl_history; History::load strips a trailing \r per line (CRLF tolerance) and decodes remaining \r back to \n. Three new tests in test/js/bun/repl/repl.test.ts cover the on-disk encoding, round-trip with blank interior lines and entry ordering, and loading a CRLF-saved file. Net Rust change is ~10 lines.

Security risks

None. The history file is user-local and already written with mode 0600; this change only alters the byte-level encoding of newlines within entries. No new syscalls, no untrusted-input parsing beyond what load already did (splitting on \n).

Level of scrutiny

Low-to-medium. The change is a targeted serialization fix in a non-hot-path CLI feature, using existing in-tree helpers (strings::replace_owned, strings::trim_suffix) and the exact encoding Node's internal/repl/history.js uses. The correctness argument (why \r is a safe sentinel and why the trailing-\r strip cannot eat entry content) checks out against the surrounding code: Key::from_byte treats byte 13 as Enter so the line editor never inserts it, and handle_enter calls history.add(strings::trim(&code, b"\n")) so a stored entry never ends in \r. Existing history files contain no interior \r, so they load unchanged.

Other factors

  • The comment-cop bot flagged long explanatory comments; the author addressed this by collapsing to a one-line doc comment on HISTORY_STORED_NEWLINE plus a short inline note in load (all threads resolved).
  • CI failures on the latest build (setInterval.test.js, html-rewriter-leak.test.ts, Windows aarch64 agent creation) are unrelated flakes touching none of the changed code.
  • Tests are hermetic (tempDir + HOME/USERPROFILE override), concurrent, and assert exact file contents plus inline snapshots of the reload session; env_var::HOME is USERPROFILE on Windows so the non-skipped block is portable.
  • cmd_save (the .save <file> REPL command) is correctly left untouched — it writes runnable source, not the history-file format.

The trailing-CR strip in History::load is the fix from #30293 (carried
by #36510 until this PR superseded it). Add that PR's case as its own
test: a history file of ordinary entries with CRLF line endings loads
without a '\r' on any entry and is written back with LF endings.

Co-authored-by: Samuel <samuelpatro@users.noreply.github.com>

@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 — small, self-contained fix that encodes multi-line REPL history entries with \r (matching Node's .node_repl_history format) so they survive a restart as one entry.

What was reviewed:

  • Verified the invariant that entries never contain \r: Key::from_byte maps CR→Enter, and handle_enter trims \n from both ends before add(), so encoded entries never end in \r and the single trim_suffix for CRLF is unambiguous.
  • Confirmed strings::trim_suffix strips exactly one occurrence and replace_owned handles the byte-for-byte swap; cmd_save (.save) intentionally left un-encoded since it writes runnable source.
  • Checked cross-platform: test sets both HOME and USERPROFILE, and env_var::HOME resolves to USERPROFILE on Windows; the arrow-up escape sequence is read byte-wise from the pipe so works with non-TTY stdin.
  • Tests cover round-trip, embedded blank lines (\r\r), CRLF file with a stored multi-line entry, and the plain CRLF single-line case from #36510.
Extended reasoning...

Overview

Two files touched: ~15 lines in src/runtime/cli/repl.rs (History::load/History::save plus a named constant) and ~140 lines of new tests in test/js/bun/repl/repl.test.ts. The Rust change replaces newlines inside a history entry with \r when writing the one-entry-per-line file, and reverses that on load after stripping a single trailing \r (CRLF line ending). This is exactly the encoding Node uses for .node_repl_history.

Security risks

None. The change only affects how the REPL's own history file is serialized/deserialized; no new syscalls, no untrusted-input parsing beyond what load already did, and the file-permission logic (0o600) is unchanged.

Level of scrutiny

Low-to-medium. The REPL history file is a convenience feature, not a hot path or security boundary. The change is mechanical (two replace_owned calls plus a trim_suffix), and the correctness argument hinges on one invariant — entries never contain \r — which I verified by tracing every path into History::add: only handle_enter calls it, with input built from the line editor (where CR maps to Key::Enter) and trimmed of \n at both ends. .load and .editor don't add to history, so they can't introduce \r either. Because entries never end in \n, encoded lines never end in \r, making the single-\r CRLF strip unambiguous even for entries whose last internal char decodes from \r.

Other factors

  • The comment-cop bot flagged verbose inline comments three times; the author reduced them to a one-line doc comment on HISTORY_STORED_NEWLINE and all threads are resolved.
  • Four new tests exercise the on-disk format directly (exact savedLines assertions), the reload behavior via .history and arrow-up recall, blank-line preservation (\r\r sequences), and CRLF interop in both the mixed and single-line-only forms — the latter subsumes #36510's test case.
  • Tests follow harness conventions: tempDir, bunEnv/bunExe via runRepl, describe.concurrent, inline snapshots, and set both HOME/USERPROFILE so env_var::HOME resolves on Windows.
  • replace_owned always allocates even when no replacement is needed, but this runs once per session on ≤1000 short entries, so it's immaterial.
  • No CODEOWNERS on this path, no outstanding human review comments, and CI is building the latest commit.

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.

1 participant