repl: keep multi-line history entries on one line of the history file - #38023
repl: keep multi-line history entries on one line of the history file#38023robobun wants to merge 6 commits into
Conversation
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>
|
Warning Review limit reached
Next review available in: 17 minutes Enable usage-based reviews in Billing to review now. Otherwise, wait until the next included review is available. How can I continue?After more reviews become available, a review can be triggered using the 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 configurationConfiguration used: Path: .coderabbit.yaml Review profile: ASSERTIVE Plan: Pro Run ID: 📒 Files selected for processing (2)
Comment |
|
Updated 4:34 PM PT - Aug 13th, 2026
✅ @robobun, your commit 718d951f103c0f22dada99282da6de565429d030 passed in 🧪 To try this PR locally: bunx bun-pr 38023That installs a local version of the PR into your bun-38023 --bun |
|
Status: reproduced on bun 1.4.0 by piping |
There was a problem hiding this comment.
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+ theload/savechanges: verified the split/join is a plain byte replace andtrim_suffixstrips exactly one trailing\r.- Checked the invariants the encoding rests on:
Key::from_bytemaps 13 to Enter so no entry contains\r, andhandle_entertrims surrounding\nbeforehistory.addso 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/USERPROFILEboth set (env_var::HOMEreadsUSERPROFILEon 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
toEqualon the saved file's physical lines, inline snapshots of the second session's output (arrow-up recall actually definesfandf()returns 1), an explicitnot.toContain("\r")guard because snapshots tolerate CRLF, and a CRLF-file re-save assertion.HOMEandUSERPROFILEare both set andenv_var::HOMEis 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).
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.
There was a problem hiding this comment.
LGTM — small, well-reasoned fix that mirrors Node's .node_repl_history encoding.
What was reviewed:
History::save/loadround-trip: verifiedreplace_ownedandtrim_suffixsemantics; entries never end in\n(trimmed inhandle_enter), so the CRLF strip is unambiguous.- Confirmed
\rcan't otherwise reach an entry (Key::from_bytemaps 13 → Enter; editor mode and.loaddon't callhistory.add). cmd_saveintentionally 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.
There was a problem hiding this comment.
LGTM — small, well-scoped fix that mirrors Node's .node_repl_history encoding.
What was reviewed
History::save/loadround-trip:\rcannot enter an entry via the line editor (Key::from_bytemaps 13 → Enter) andadd()gets\n-trimmed input, so the trailing-\rstrip is unambiguous vs. CRLF endings.- Confirmed
strings::replace_owned/strings::trim_suffixsignatures match usage;env_var::HOMEreadsUSERPROFILEon Windows so the new tests (which set both) are cross-platform. cmd_saveintentionally 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_NEWLINEplus a short inline note inload(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/USERPROFILEoverride), concurrent, and assert exact file contents plus inline snapshots of the reload session;env_var::HOMEisUSERPROFILEon 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>
There was a problem hiding this comment.
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_bytemaps CR→Enter, andhandle_entertrims\nfrom both ends beforeadd(), so encoded entries never end in\rand the singletrim_suffixfor CRLF is unambiguous. - Confirmed
strings::trim_suffixstrips exactly one occurrence andreplace_ownedhandles the byte-for-byte swap;cmd_save(.save) intentionally left un-encoded since it writes runnable source. - Checked cross-platform: test sets both
HOMEandUSERPROFILE, andenv_var::HOMEresolves toUSERPROFILEon 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_NEWLINEand all threads are resolved. - Four new tests exercise the on-disk format directly (exact
savedLinesassertions), the reload behavior via.historyand arrow-up recall, blank-line preservation (\r\rsequences), 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/bunExeviarunRepl,describe.concurrent, inline snapshots, and set bothHOME/USERPROFILEsoenv_var::HOMEresolves on Windows. replace_ownedalways 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.
Problem
bun replkeeps a multi-line input (a function typed over three lines, say) as one history entry, butHistory::saveinsrc/runtime/cli/repl.rswrites every entry verbatim followed by\n, so that entry lands in~/.bun_repl_historyas three physical lines.History::loadsplits 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.historylists the fragments. Within the session that typed it, the same input is one entry, so only the file round trip is wrong.\n-only split keeps the\rof a CRLF line ending (a history file written on Windows or re-saved by a CRLF editor) on every loaded entry, so.historyprintsold_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).printf 'function f() {\n return 1\n}\n.exit\n' | HOME=/tmp/h bun repl, then.historyin a secondHOME=/tmp/h bun repllists 3 entries. For the CRLF case:printf 'old_one\r\nold_two\r\n' > /tmp/h/.bun_repl_history, then.historyinHOME=/tmp/h bun replprints1 old_one\r.Fix
savewrites the newlines inside an entry asHISTORY_STORED_NEWLINE(\r), so each entry is exactly one line of the file.loaddrops a CRLF line ending and turns the remaining\rs back into\n. Both directions arestrings::replace_owned.\rnever 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\nin 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.jsswaps\nand\rwhen storing an entry and keeps one entry per line of.node_repl_history).handle_enter), so a stored entry never ends in\r; stripping one trailing\rper 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+/.\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..history, recall, the last-entry duplicate check and.save(which writes source code, where the newlines belong) are unchanged.REPL history fileblock intest/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.cargo fmt --checkandcargo clippy -p bun_runtimeare clean.Relation to #36510 and #30293
loadis the fix @samuelpatro contributed in repl: strip trailing CR when loading history (Windows CRLF) #30293 (against the Zig REPL); repl: strip trailing CR when loading history (Windows CRLF) #36510 re-applied it torepl.rsand has been closed in favour of this PR, which needs the same strip for the decode. The commits here carry theCo-authored-bytrailer from those PRs, and repl: strip trailing CR when loading history (Windows CRLF) #36510's test case is the last test in the block.\rand re-saves the file with LF endings.Background
multiline_bufferand, once the code parses, adds the whole buffer (embedded newlines included) as a single entry.$HOME/.bun_repl_history(USERPROFILEon 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.refresh_lineand 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