Skip to content

Implement node:repl start(), REPLServer, and Recoverable - #28480

Closed
robobun wants to merge 10 commits into
mainfrom
farm/c294eb07/implement-repl-start
Closed

Implement node:repl start(), REPLServer, and Recoverable#28480
robobun wants to merge 10 commits into
mainfrom
farm/c294eb07/implement-repl-start

Conversation

@robobun

@robobun robobun commented Mar 23, 2026

Copy link
Copy Markdown
Collaborator

Problem

repl.start is not implemented in Bun (#28478). The node:repl module was a stub that threw NotImplementedError on most access, making packages that depend on node:repl fail.

import repl from "node:repl";
repl.start("$ ");
// TypeError: repl.start is not a function

Root Cause

The module at src/js/node/repl.ts exported a plain object with proxy traps that threw on access — no start() function, no REPLServer class.

Fix

Replaced the stub with a working implementation:

  • REPLServer class extending readline.Interface — reads input, evaluates JavaScript, prints results
  • repl.start(options) factory function accepting a string prompt or options object
  • Recoverable error class for multi-line input detection
  • REPL_MODE_SLOPPY and REPL_MODE_STRICT constants
  • Built-in dot-commands: .help, .exit, .clear, .break, .editor, .save, .load
  • Default eval using indirect eval (useGlobal: true) or vm.runInContext (sandboxed)
  • Default writer using util.inspect
  • defineCommand(), displayPrompt(), clearBufferedCommand(), setupHistory()
  • Underscore (_) tracking for last result

Verification

  • USE_SYSTEM_BUN=1 bun test test/regression/issue/28478.test.ts0/10 pass (bug exists)
  • bun bd test test/regression/issue/28478.test.ts10/10 pass (fix works)

Closes #28478


Verified by @robobun (iteration 3, commit 93b9465): Lint JavaScript passes, Format pending, Buildkite pipeline passed (Build #41488 started, JS-only change — no native code touched). Diff clean: no TODO/FIXME/HACK, only src/js/node/repl.ts and test/regression/issue/28478.test.ts modified. Main branch confirmed to have zero start/REPLServer/Recoverable/REPL_MODE/writer exports (stub with throwNotImplemented); all 10 tests would fail on main. Tests exercise typeof checks, real eval (1+2→3), instanceof REPLServer, string prompt shorthand, new REPLServer() constructor, .exit dot-command, and writer export. Initial blocking review findings (defaultWriter.options mutation, editor mode close, unknown dot-commands, underscore tracking, SIGINT double-press, isRecoverableError patterns) all addressed in 9f7182b. Remaining CodeRabbit suggestions (object literal wrapping, defineCommand validation, this parameter types, history persistence, .load blank lines) are all enhancements suitable for follow-ups. No blocking reviews.

Verified by @robobun (iteration 1 of Verify, commit aaadd02): Lint JavaScript ✅ pass. Format and Buildkite Build #41495 still pending (all build jobs in Started state — JS-only change, no native code touched). Diff clean: no TODO/FIXME/HACK/XXX in added lines, only src/js/node/repl.ts and test/regression/issue/28478.test.ts modified. 10 regression tests all spawn isolated subprocesses; all would fail on main where repl.start/REPLServer/Recoverable/REPL_MODE_*/writer were not exported (stub with throwNotImplemented). Tests exercise typeof checks, real eval (1+2=3), instanceof REPLServer, string prompt shorthand, new REPLServer() constructor, .exit dot-command, and writer export. 0 CHANGES_REQUESTED reviews; CodeRabbit blocking items (defaultWriter.options mutation, unknown dot-commands, SIGINT double-press, isRecoverableError patterns) addressed in 9f7182b; remaining suggestions (object literal wrapping, defineCommand validation, this parameter types, history persistence) are follow-up enhancements. Human reviewer: please confirm Buildkite green before merge.

Verified by @robobun (iteration 2 of Verify, commit dcc18dc): Lint JavaScript ✅ passed. Format and Buildkite Build #41501 still in progress (JS-only change, no native code). Diff clean: no TODO/FIXME/HACK/XXX, only src/js/node/repl.ts and test/regression/issue/28478.test.ts modified. 10 regression tests all spawn isolated subprocesses; all would fail on main where repl.start/REPLServer/Recoverable/REPL_MODE_*/writer are not exported (stub with throwNotImplemented). Tests exercise typeof checks, real eval (1+2=3), instanceof REPLServer, string prompt shorthand, new REPLServer() constructor, .exit dot-command, and writer export. 0 CHANGES_REQUESTED reviews; CodeRabbit blocking items addressed; remaining suggestions are follow-up enhancements. Human reviewer: please confirm Buildkite green before merge.

@robobun

robobun commented Mar 23, 2026

Copy link
Copy Markdown
Collaborator Author
Updated 11:27 AM PT - Mar 25th, 2026

@robobun, your commit 045ef0c has 5 failures in Build #41986 (All Failures):


🧪   To try this PR locally:

bunx bun-pr 28480

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

bun-28480 --bun

@coderabbitai

coderabbitai Bot commented Mar 23, 2026

Copy link
Copy Markdown
Contributor

Note

Reviews paused

It looks like this branch is under active development. To avoid overwhelming you with review comments due to an influx of new commits, CodeRabbit has automatically paused this review. You can configure this behavior by changing the reviews.auto_review.auto_pause_after_reviewed_commits setting.

Use the following commands to manage reviews:

  • @coderabbitai resume to resume automatic reviews.
  • @coderabbitai review to trigger a single review.

Use the checkboxes below for quick actions:

  • ▶️ Resume reviews
  • 🔍 Trigger review

Walkthrough

Replaced the node:repl stub with a complete REPL implementation and added regression tests: exports now include start, REPLServer, Recoverable, REPL_MODE_SLOPPY/REPL_MODE_STRICT, writer, and builtinModules; evaluation, dot-commands, multiline/editor buffering, and SIGINT handling implemented.

Changes

Cohort / File(s) Summary
REPL Implementation
src/js/node/repl.ts
Replaced stub with a full REPL: added start(options?), REPLServer, Recoverable, REPL_MODE_SLOPPY/REPL_MODE_STRICT, builtinModules and _builtinLibs, and exported writer. Implemented defaultEval (global vs vm), recoverable syntax detection, prompt/editor modes, multiline buffering, _/_error tracking, dot-command registry/dispatch, readline wiring, and SIGINT/double-Ctrl+C behavior.
REPL Tests
test/regression/issue/28478.test.ts
Added tests that spawn isolated Bun processes to require('node:repl'), assert exported types/symbols and writer, exercise repl.start() (including custom prompt and stream overrides), validate evaluation output and prompt behavior, test .exit handling and REPLServer instantiation/events.
🚥 Pre-merge checks | ✅ 4
✅ Passed checks (4 passed)
Check name Status Explanation
Title check ✅ Passed The title accurately and concisely summarizes the main changes: implementing three key exports (start(), REPLServer, Recoverable) for the node:repl module.
Linked Issues check ✅ Passed The PR directly implements all primary requirements from #28478: repl.start() function, REPLServer class, Recoverable error class, REPL_MODE constants, dot-commands, default eval/writer behavior, and regression tests validating these features.
Out of Scope Changes check ✅ Passed All changes are directly scoped to implementing node:repl module functionality requested in #28478; modifications limited to src/js/node/repl.ts and the regression test file.
Description check ✅ Passed The PR description is comprehensive, well-structured, and includes all required sections from the template (What does this PR do and How did you verify your code works).

✏️ Tip: You can configure your own custom pre-merge checks in the settings.


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

@github-actions

Copy link
Copy Markdown
Contributor

Found 4 issues this PR may fix:

  1. jest error TypeError: undefined is not an object (evaluating '_global.hasOwnProperty("queueMicrotask")') #11075 - Jest compatibility issue with missing hasOwnProperty in VM contexts, which could be resolved by proper REPLServer context management
  2. node:vm microtaskMode: 'afterEvaluate' doesn't seem to be respected #20145 - VM microtaskMode not respected in vm.runInContext, directly affects REPL code evaluation behavior
  3. process.on 'unhandledRejection' does not work with vm.runInContext #14766 - Unhandled rejection handling broken with vm.runInContext, could improve REPL error handling with Recoverable implementation
  4. readline.createInterface setRawMode throws error in non-interactive terminal when it shouldn't #5832 - readline.Interface setRawMode errors in non-interactive terminals, affects REPLServer in CI/Docker environments

If this is helpful, consider adding Fixes # to the PR description to auto-close the issue on merge.

🤖 Generated with Claude Code

Comment thread src/js/node/repl.ts
Comment thread src/js/node/repl.ts
Comment thread src/js/node/repl.ts

@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 the current code and only fix it if needed.

Inline comments:
In `@src/js/node/repl.ts`:
- Around line 273-276: The current branch mutates the shared defaultWriter when
enabling colors because writer === defaultWriter and assigning writer.options
alters the global object; instead, when useColors is true and writer ===
defaultWriter create a per-instance writer clone so the default isn't modified
(e.g., replace writer with a shallow copy of defaultWriter and a cloned options
object that has colors: true). Update the logic around writer, defaultWriter,
useColors and writer.options to assign a new writer object ({ ...defaultWriter,
options: { ...defaultWriter.options, colors: true } }) rather than mutating
defaultWriter.options in place.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: ASSERTIVE

Plan: Pro

Run ID: 3f876969-d48c-4c98-922a-b1b3ef885709

📥 Commits

Reviewing files that changed from the base of the PR and between 2920fac and a2d147b.

📒 Files selected for processing (2)
  • src/js/node/repl.ts
  • test/regression/issue/28478.test.ts

Comment thread src/js/node/repl.ts Outdated
Comment thread src/js/node/repl.ts
Comment thread src/js/node/repl.ts
Comment thread src/js/node/repl.ts
Comment thread src/js/node/repl.ts

@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: 6

🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@src/js/node/repl.ts`:
- Around line 441-489: Annotate the exported prototype methods with explicit
this parameter types: add a typed this (e.g., this: REPLServer or appropriate
interface) to REPLServer.prototype.defineCommand, displayPrompt,
clearBufferedCommand, setupHistory, and createContext so they do not rely on
implicit any; locate the functions by name (defineCommand, displayPrompt,
clearBufferedCommand, setupHistory, createContext) and update their signatures
to include the correct this type consistent with the REPLServer class/interface
used elsewhere so Bun’s direct-binding conventions are preserved.
- Around line 441-449: validate inputs in REPLServer.prototype.defineCommand:
ensure keyword is a non-empty string and cmd is either a function or an object
with an "action" property that is a function; use validators from
internal/validators and throw the appropriate $ERR_* errors (e.g.,
ERR_INVALID_ARG_TYPE or ERR_INVALID_ARG_VALUE) when checks fail instead of
storing the invalid value in this.commands; if cmd is a function, wrap it into
an object as before after validation, and ensure the stored command has a
callable action (not just any value) so action.$call won’t fail later.
- Around line 258-261: The problem is that this.underscoreAssigned and
this.underscoreErrAssigned are never set so the REPL always overwrites
user-defined _ and _error; fix by detecting when the user explicitly assigns to
'_' or '_error' and set the corresponding flag to true so subsequent evaluations
skip overwriting. Concretely: in the REPL evaluation/command handling code that
processes user input (the same area that currently assigns this.last and
this.lastError after evaluation), add logic to detect user-side assignments to
identifiers '_' or '_error' (e.g., via the parsed AST token/assignment check or
by inspecting the input string for a top-level assignment like "_ =" or "_error
=") and when such an assignment is observed set this.underscoreAssigned = true
or this.underscoreErrAssigned = true respectively; keep the existing checks that
avoid overwriting when the flags are true and optionally add a way (command or
explicit reset) to clear these flags if you want to re-enable automatic updates.
- Around line 378-399: The close handler for the REPL's editor mode currently
emits "exit" after evaluating buffered code (in the this.on("close") block
handling this.editorMode and this._bufferedCommand), which terminates the
session; instead restore the pre-editor prompt and re-prompt the user:
capture/retain the prompt used before entering editor mode (e.g., savedPrompt
when toggling this.editorMode), and replace the this.emit("exit") call in the
_eval.$call callback with this.setPrompt(savedPrompt) followed by this.prompt()
so the REPL returns to the normal prompt rather than exiting.
- Around line 459-479: The setupHistory implementation only reads history and
never persists changes, and it swallows read errors; update
REPLServer.prototype.setupHistory to (1) report read errors to the caller by
passing the Error to callback (use callback.call(this, err, this) on failure)
instead of ignoring the catch, and (2) register a shutdown/exit handler on the
REPL instance (this) that writes the current this.history back to historyFile
(create parent dir if needed, write atomically or via temporary file then
rename, or append new entries) so session commands are persisted; ensure any
write errors are surfaced to the callback or emitted appropriately.
- Around line 105-110: In defaultEval, before passing the prepared code
(useStrict) to indirectEval or vm.runInContext, detect object-literal-like input
(e.g., trimmed code starts with '{' and is not a block or function body) and
wrap it in parentheses so expressions like "{ a: 1 }" are treated as object
literals; modify the flow around the useStrict variable in defaultEval to
transform useStrict to `(${useStrict})` when the pattern matches, then continue
calling indirectEval(useStrict) or vm.runInContext(useStrict, context, {
filename }) as before.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: ASSERTIVE

Plan: Pro

Run ID: f0b58fa8-346b-4e66-b76b-259dc501f556

📥 Commits

Reviewing files that changed from the base of the PR and between a2d147b and 9f7182b.

📒 Files selected for processing (1)
  • src/js/node/repl.ts

Comment thread src/js/node/repl.ts
Comment thread src/js/node/repl.ts
Comment thread src/js/node/repl.ts
Comment thread src/js/node/repl.ts
Comment thread src/js/node/repl.ts
Comment thread src/js/node/repl.ts
Comment thread src/js/node/repl.ts
Comment thread src/js/node/repl.ts
Comment thread src/js/node/repl.ts Outdated
Comment thread test/regression/issue/28478.test.ts

@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: 5

🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@src/js/node/repl.ts`:
- Line 287: The code captures a startup snapshot in savedPrompt and then
force-restores it after every eval/SIGINT which clobbers later setPrompt()
calls; instead, remove unconditional restores of the constructor-time
savedPrompt from the eval and SIGINT paths (the places using savedPrompt at the
eval handler and onSigint), and change the logic to only save the current prompt
immediately before any temporary prompt change and restore that captured value
right after that temporary operation completes (i.e., use a local
tempSavedPrompt around the specific code that calls setPrompt for transient
prompts), leaving normal setPrompt() calls by host/user intact; reference
symbols: savedPrompt, prompt, setPrompt, the repl eval handler (where
savedPrompt is used), and the SIGINT handler/onSigint (where savedPrompt is
restored).
- Line 296: The current assignment historySize: options.historySize || 1000
ignores explicit zero; change the truthy check to a nullish/undefined check so
an explicit 0 is preserved (e.g., replace the use of the || fallback with a
nullish coalescing or explicit undefined check where historySize is set in
repl.ts so options.historySize === 0 remains 0 rather than defaulting to 1000).
- Around line 488-495: The createContext function diverges from the
constructor/.clear sandbox by calling vm.createContext() without the globalThis
seed and thus omits expected globals like console/process; update
REPLServer.prototype.createContext to reuse the same sandbox factory used in the
constructor and .clear by calling vm.createContext(globalThis) (or otherwise
seeding the new context with globalThis) when this.useGlobal is false so the
created context includes console, process and other globals the REPL
implementation relies on.
- Around line 181-186: The .load implementation is dropping blank lines because
of the if (line) filter; fix by preserving empty lines when replaying the file:
remove the if (line) check and write every line (e.g., iterate lines and call
this.write(line + "\n") for each) or simply call this.write(data) to replay the
exact file contents; refer to the variables/methods in this snippet (file, data,
lines, this.write) when applying the change.
- Around line 305-320: The REPL currently parses and dispatches dot-commands in
the line handler even when a multiline buffer is active; modify the handler in
the this.on("line", ...) block to first check the multiline state
(this._bufferedCommand or equivalent buffer) and only parse dot-commands if the
buffer is empty (or only allow explicit escape commands like ".break");
concretely, wrap the existing dot-command branch that uses trimmedLine.match and
this.commands[keyword].action.$call with a guard that returns false when
this._bufferedCommand is non-empty (or alternatively whitelist specific keywords
such as "break"), ensuring continued multiline expressions starting with "." are
treated as JavaScript input rather than REPL commands and preserving calls to
this.displayPrompt() and this.outputStream.write unchanged for true command
dispatch.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: ASSERTIVE

Plan: Pro

Run ID: c869e412-5f1c-441e-9712-08ec132fb9dd

📥 Commits

Reviewing files that changed from the base of the PR and between 9f7182b and f78d3fa.

📒 Files selected for processing (2)
  • src/js/node/repl.ts
  • test/regression/issue/28478.test.ts

Comment thread src/js/node/repl.ts
Comment thread src/js/node/repl.ts
Comment thread src/js/node/repl.ts Outdated
Comment thread src/js/node/repl.ts
Comment thread src/js/node/repl.ts
Comment thread src/js/node/repl.ts

@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: 2

🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@src/js/node/repl.ts`:
- Around line 163-168: The .save action is writing every raw input from
this.lines but raw lines are currently appended before evaluation (see the
handler that pushes into this.lines when input first arrives and the
REPLServer.save action), so aborted recoverable input (e.g., .break/Ctrl+C) or
editor-mode code get incorrectly recorded or omitted; fix by moving the
append-to-this.lines logic out of the initial input handler and instead add
committed lines only after a command is successfully evaluated/committed (i.e.,
after the evaluation callback/promise resolves without a recoverable abort), and
ensure editor-mode submissions are also recorded at the same commit point so
.save serializes only executed commands (update the input-push site and
references in the REPLServer.save action and the evaluation callback/commit
path).
- Around line 465-477: The setupHistory function is loading all lines from
historyFile into this.history without honoring the configured historySize;
update REPLServer.prototype.setupHistory to enforce historySize by (a) if
this.historySize === 0 skip loading entirely, otherwise (b) only load the last
this.historySize entries from the file or push all then trim this.history to
keep at most this.historySize items; use the this.historySize property and the
historyFile / this.history symbols to identify where to apply the slice/trim so
the in-memory history never exceeds the configured limit.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: ASSERTIVE

Plan: Pro

Run ID: c46ebf6e-9241-4e1c-a364-f0db85a7b658

📥 Commits

Reviewing files that changed from the base of the PR and between f78d3fa and 2a925f3.

📒 Files selected for processing (1)
  • src/js/node/repl.ts

Comment thread src/js/node/repl.ts
Comment thread src/js/node/repl.ts
Comment thread src/js/node/repl.ts
Comment thread src/js/node/repl.ts
Comment thread src/js/node/repl.ts

@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


ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: ASSERTIVE

Plan: Pro

Run ID: 72f4a4ec-a509-492c-9f3d-e774601d0124

📥 Commits

Reviewing files that changed from the base of the PR and between 2a925f3 and 93b9465.

📒 Files selected for processing (1)
  • src/js/node/repl.ts

Comment thread src/js/node/repl.ts
Comment thread src/js/node/repl.ts
Comment thread src/js/node/repl.ts
Comment thread src/js/node/repl.ts
Comment thread test/regression/issue/28478.test.ts
Comment thread src/js/node/repl.ts
Comment thread src/js/node/repl.ts
Comment thread src/js/node/repl.ts
Comment thread src/js/node/repl.ts Outdated
Comment thread src/js/node/repl.ts
Comment thread src/js/node/repl.ts
robobun and others added 10 commits March 25, 2026 16:43
Replaces the stub node:repl module with a working implementation:

- REPLServer class extending readline.Interface
- repl.start(options) factory accepting string prompt or options object
- Recoverable error class for multi-line input detection
- REPL_MODE_SLOPPY and REPL_MODE_STRICT constants
- Built-in commands: .help, .exit, .clear, .break, .editor, .save, .load
- Default eval using indirect eval (useGlobal) or vm.runInContext
- Default writer using util.inspect
- defineCommand(), displayPrompt(), clearBufferedCommand(), setupHistory()
- Underscore tracking for last result

Closes #28478
… dot-commands, underscore, double Ctrl+C

- Per-instance writer closure instead of mutating shared defaultWriter.options
- Editor mode: evaluate buffered content on Ctrl+D, reset editorMode on Ctrl+C
- Tighten isRecoverableError to only match genuinely incomplete input
- Unknown dot-commands print 'Invalid REPL keyword' instead of falling through
- Assign _ and _error into evaluation context after each eval
- Track _sawSigint for double Ctrl+C exit
…t tests

- Populate vm.createContext with globalThis so console/process/etc are available
- Empty lines silently re-prompt instead of printing 'undefined'
- Use Symbol() instead of Symbol.for() to match Node.js behavior
- Use describe.concurrent for parallel test execution
Store _initialPrompt on instance so .break action can reset the prompt
after escaping multiline mode, matching SIGINT handler behavior.
…okkeeping

- Expose .options on per-instance colored writer so replServer.writer.options.depth works
- Editor-mode close handler now updates this.lines, context._, and context._error
- .clear now resets prompt from "... " to initial, matching .break
- builtinModules entry changed from "node:test" to "test" for consistency
Restrict regex to valid JS identifiers so .1, .5e3 etc fall through to eval
@robobun
robobun force-pushed the farm/c294eb07/implement-repl-start branch from dcc18dc to 045ef0c Compare March 25, 2026 16:43
Comment thread src/js/node/repl.ts
Comment on lines +177 to 193
load: {
help: "Load JS from a file into the REPL session",
action(this: InstanceType<typeof REPLServer>, file: string) {
const fs = require("node:fs");
try {
const data = fs.readFileSync(file, "utf8");
const lines = data.split("\n");
for (const line of lines) {
if (line) {
this.write(line + "\n");
}
}
} catch (e: any) {
this.outputStream.write(`Failed to load: ${e.message}\n`);
}
this.displayPrompt();
},

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 .load command produces a duplicate prompt after the last evaluated line. When this.write(line + "\n") is called for each file line, it synchronously emits the line event, drives eval, and calls this.prompt() -- so the final line already shows a prompt. The unconditional this.displayPrompt() at line 192 (outside the try/catch) then shows a second one. Fix: move this.displayPrompt() inside the catch block only, removing it from the success path.

Extended reasoning...

Bug: .load double-prompt after last evaluated line

What the bug is

The .load dot-command in src/js/node/repl.ts (lines 177-193) calls this.displayPrompt() unconditionally after its try/catch block. On the success path, this results in the REPL prompt being printed twice after the last file line is evaluated.

The code path that triggers it

In Bun's readline implementation, readline.Interface.write() processes input through kNormalWrite -> kOnLine -> emit("line") synchronously. The line event handler in REPLServer calls this._eval.$call(...), and defaultEval invokes vm.runInContext(...) which is also synchronous. The eval callback therefore runs inline, and on success it executes this.setPrompt(savedPrompt); this.prompt() -- printing the first prompt.

After the for loop completes (having processed the last line of the file), execution falls through to the unconditional this.displayPrompt() at line 192, which calls this.prompt() a second time.

The relevant code path:

  • this.write(line + "\n") triggers emit("line") synchronously
  • The line handler calls eval, eval callback calls this.prompt() [FIRST prompt]
  • After the for-loop, this.displayPrompt() calls this.prompt() again [SECOND prompt]

Why existing code does not prevent it

The displayPrompt() call is placed after the entire try/catch block, so it runs regardless of whether any lines were evaluated. The author likely intended it as a 'show the prompt when done' safeguard, but did not account for the fact that each this.write(line + "\n") already shows a prompt through the synchronous eval chain.

Impact

The REPL outputs a stray extra prompt line after .load finishes. If the loaded file has N non-empty lines, the user sees N+1 prompts printed where N are expected -- the final prompt appears duplicated. This is cosmetically incorrect and inconsistent with Node.js behavior.

How to fix it

Remove this.displayPrompt() from the post-try/catch location and move it exclusively inside the catch block. On the error path, no this.write() calls were made (the file failed to open), so no prompt has been shown yet and displayPrompt() is still needed there. On the success path, the last this.write() already triggered the prompt via the eval chain.

Step-by-step proof

  1. User runs .load script.js where script.js contains a single line const x = 1
  2. .load action calls this.write("const x = 1\n")
  3. readline.Interface.write -> kNormalWrite -> kOnLine -> emit("line", "const x = 1") (synchronous)
  4. The line handler calls this._eval.$call(this, "const x = 1\n", ...)
  5. defaultEval calls vm.runInContext("const x = 1\n", ...) -- returns synchronously
  6. Eval callback fires: this.setPrompt(savedPrompt); this.prompt() -- FIRST prompt printed (> )
  7. this.write() returns; the for-loop ends
  8. Execution reaches line 192: this.displayPrompt() -> this.prompt() -- SECOND prompt printed (> )
  9. User sees two consecutive > prompts on screen

Comment thread src/js/node/repl.ts
Comment on lines +501 to +503
} catch {
// File doesn't exist yet, that's fine
}

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 setupHistory catch block at lines 501-503 silently swallows ALL errors from fs.readFileSync, not just ENOENT. This means errors like EACCES (permission denied), EMFILE (too many open files), or EISDIR (path is a directory) are silently ignored, and the callback always receives null as the error argument — falsely signaling success. The fix is to inspect err.code inside the catch and only suppress ENOENT, forwarding all other errors to the callback.

Extended reasoning...

Bug: setupHistory swallows all errors instead of only ENOENT

What the bug is

In REPLServer.prototype.setupHistory, the code uses a bare catch {} block when reading the history file. The comment says // File doesnt exist yet, thats fine, but the catch block traps every possible exception — not just the ENOENT case where the file simply does not exist yet.

The specific code path

try {
  const data = fs.readFileSync(historyFile, "utf8");
  // ... process history lines
} catch {
  // File doesnt exist yet, thats fine  ← comment is wrong/incomplete
}
// Always signals success:
if (typeof callback === "function") {
  callback.$call(this, null, this);  // null = no error
}

The catch {} captures: ENOENT (no such file), EACCES (permission denied), EMFILE (too many open file descriptors), ENOMEM (out of memory), EISDIR (path points to a directory), and any other fs.readFileSync failure.

Why existing code does not prevent it

The catch block has no condition — it unconditionally suppresses every thrown error. The callback is then called with null as the first argument regardless of what went wrong. There is no inspection of err.code to distinguish a benign "file not yet created" situation from a genuine I/O failure.

Impact

Code that calls repl.setupHistory("/restricted/history", callback) will receive callback(null, repl) even when the history file is unreadable due to permissions or other I/O errors. The caller has no way to know that history was silently skipped. In Node.js, only ENOENT is suppressed — all other errors are forwarded to the callback so the caller can decide how to handle them. This is a behavioral divergence from Node.js compatibility.

How to fix it

} catch (err: any) {
  if (err.code !== "ENOENT") {
    if (typeof callback === "function") {
      callback.$call(this, err, this);
    }
    return;
  }
  // ENOENT: file does not exist yet, that is fine
}

Step-by-step proof

  1. User creates a REPL and calls repl.setupHistory("/root/protected_history", cb) where /root/protected_history is owned by root and not readable by the current user.
  2. fs.readFileSync("/root/protected_history", "utf8") throws { code: "EACCES", message: "EACCES: permission denied" }.
  3. The bare catch {} captures this error and discards it — no err.code check is performed.
  4. Execution falls through to callback.$call(this, null, this) — the null signals "no error".
  5. The callback assumes history was loaded successfully and proceeds, completely unaware of the permission failure.
  6. In Node.js, the same scenario results in callback(err, repl) where err is the EACCES error, giving the caller the chance to surface or handle it.

@robobun

robobun commented May 27, 2026

Copy link
Copy Markdown
Collaborator Author

Issue #31470 (ts-node's ReplService.start failing with TypeError: (0, repl_1.start) is not a function) is a duplicate of #28478 and is covered by this PR.

This PR has been red across all Buildkite lanes and is ~2 months behind main, so I rebuilt the same feature against current main and verified it on the ts-node reproduction. In case it's useful for refreshing this PR, that branch is farm/98265b58/node-repl-start.

Diff summary of that branch (same public surface as here: start, REPLServer, Recoverable, REPL_MODE_SLOPPY/REPL_MODE_STRICT, writer, default commands, defineCommand, _/_error):

  • REPLServer subclasses readline.Interface via $toClass (top-level Object.setPrototypeOf(REPLServer.prototype, Interface.prototype) throws during builtin init because property access on required classes isn't reliable there).
  • Recoverable is class Recoverable extends SyntaxError (a top-level Object.setPrototypeOf(Recoverable.prototype, SyntaxError.prototype) hits Cannot set prototype of undefined or null in the builtin global).
  • The eval property is read/written via a string key since eval is a reserved identifier in the builtin bundler.
  • Default eval compiles with vm.Script and detects incomplete multiline input from JSC's parse error (vm.Script compiles lazily, so the SyntaxError surfaces at execution — before any side effects).
  • Errors thrown from a vm context aren't instanceof Error in the REPL realm, so they're formatted structurally (Uncaught <name>: <message>) instead of inspecting to {}.

Test test/js/node/repl.test.ts fails on the baked stub ((0, repl_1.start) is not a function) and passes on the rebuilt branch (4/4).

@robobun

robobun commented Aug 12, 2026

Copy link
Copy Markdown
Collaborator Author

node:repl landed on main in #31827, which closed #28478. start(), REPLServer, and Recoverable are all implemented in src/js/node/repl.js now, and this PR's test file passes against main unchanged. Closing this earlier implementation.

@robobun robobun closed this Aug 12, 2026
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.

repl.start not implemented in Bun

1 participant