readline: reject instead of throwing when question() gets an aborted signal - #33343
readline: reject instead of throwing when question() gets an aborted signal#33343robobun wants to merge 2 commits into
Conversation
…signal Promise.reject needs the Promise constructor as its receiver, so a detached `Promise.$reject` threw "|this| is not an object" on the already-aborted fast path of Interface.prototype.question. Bind the same two other detached captures: internal/fs/cp.ts and PromiseAll in internal/primordials.js (used by SafePromiseAll).
|
No actionable comments were generated in the recent review. 🎉 ℹ️ Recent review info⚙️ Run configurationConfiguration used: Path: .coderabbit.yaml Review profile: ASSERTIVE Plan: Pro Run ID: 📒 Files selected for processing (4)
WalkthroughInternal Promise helper references (PromiseReject in cp.ts and readline.ts, PromiseAll in primordials.js) are changed to bound versions via .bind(Promise). New tests are added for readline/promises question() rejection behavior when an AbortSignal is already aborted or aborts after prompting, including a promisify-wrapped scenario. ChangesBound Promise helper fixes
Sequence Diagram(s)See hidden artifact diagram for the readline abort-signal rejection flow. Compact metadata
Related issues: None specified Poem 🚥 Pre-merge checks | ✅ 4✅ Passed checks (4 passed)
Comment |
|
Updated 11:19 PM PT - Jul 4th, 2026
❌ @robobun, your commit aefe760 has 2 failures in
🧪 To try this PR locally: bunx bun-pr 33343That installs a local version of the PR into your bun-33343 --bun |
|
Found 1 issue this PR may fix:
🤖 Generated with Claude Code |
|
This PR may be a duplicate of:
🤖 Generated with Claude Code |
There was a problem hiding this comment.
LGTM — mechanical .bind(Promise) fix matching the existing pattern across src/js, with tests covering both entry points.
Extended reasoning...
Overview
Three one-line source changes, each adding .bind(Promise) to a detached static Promise method capture:
src/js/node/readline.ts—PromiseReject(the reported bug:question()with a pre-aborted signal threw|this| is not an objectinstead of returning a rejected promise)src/js/internal/fs/cp.ts—PromiseRejectinpathExistsRejectedsrc/js/internal/primordials.js—PromiseAllused bySafePromiseAll
Plus three new test cases in test/js/node/readline/readline_promises.node.test.ts covering the readline/promises path, the util.promisify(rl.question) path, and the late-abort path.
Security risks
None. This is internal plumbing for how Node-compat built-ins construct rejected promises; no user input handling, auth, crypto, or filesystem semantics change.
Level of scrutiny
Low. Promise.reject / Promise.all use NewPromiseCapability(this) per spec, so calling them without a receiver throws — a well-known JS gotcha. Adding .bind(Promise) is strictly additive: it cannot break any call site that worked before, only fixes the ones that threw. I grepped src/js and confirmed (a) this exact .bind(Promise) pattern is already used in 10+ other captures (streams/operators.ts, diagnostics_channel.ts, vm.ts, webstreams_adapters.ts, etc.), and (b) no remaining unbound = Promise.$xxx; captures exist after this PR.
Other factors
The tests are well-constructed: they reuse the existing FakeInput fixture, use using for cleanup, assert specific error shape (name, code, cause) rather than bare toThrow(), and verify the negative contract (prompt not written when pre-aborted). The PR description clearly explains the root cause and verified the tests fail on the unfixed build. The two sibling fixes (cp.ts, primordials.js) are exactly the "fix the whole class" pattern the repo guidelines ask for.
|
On the two bot suggestions:
Nothing in Duplicate of #30940 — same three source lines, from an earlier run of mine that I missed when checking for an existing PR. I've closed #30940 in favor of this one. Its regression test ( |
There was a problem hiding this comment.
LGTM — three one-line .bind(Promise) fixes matching the existing pattern, with tests that exercise the previously-broken path.
Extended reasoning...
Overview
Three source lines changed, all the same shape: a Promise static captured as a free function without its receiver is now bound to Promise.
src/js/node/readline.ts—PromiseReject = Promise.$reject.bind(Promise)src/js/internal/fs/cp.ts— samesrc/js/internal/primordials.js—PromiseAll = Promise.all.bind(Promise)
Plus three new tests in test/js/node/readline/readline_promises.node.test.ts covering rl.question() with a pre-aborted signal (both readline/promises and util.promisify paths) and a late-aborting signal.
Security risks
None. No user input parsing changes, no auth/crypto, no new surface. Binding the receiver is strictly a correctness fix for the existing call sites.
Level of scrutiny
Low. This is a mechanical bug fix with an unambiguous root cause: Promise.reject / Promise.all use NewPromiseCapability(this) and throw |this| is not an object when called with an undefined receiver. The fix (.bind(Promise)) is the established idiom already used ~10 times across src/js — the very next line in primordials.js (PromiseResolve) was already bound this way. Each changed line has exactly one possible correct fix and this is it.
Other factors
- Tests are well-structured: reuse the file's existing
FakeInputhelper, useusingfor cleanup, assert specific{name, code, cause}rather than baretoThrow, and the PR shows they fail on the unfixed build with the exact reportedTypeError. - The
primordials.jshunk (SafePromiseAll) has no live caller today, which the author acknowledges as a latent-correctness fix; that's fine — it's still obviously correct and prevents the same bug from reappearing when a caller is added. - No CODEOWNERS cover the touched files. No outstanding human review comments.
node runs the whole body of readline/promises question() inside a new Promise(...) executor, so an invalid options.signal surfaces as a rejected promise rather than a synchronous ERR_INVALID_ARG_TYPE. Move validateAbortSignal and the abort wiring inside the try so the promise capability absorbs them, the way the executor does. This also routes the already-aborted fast path through the capability's reject instead of the bare PromiseReject. The promisify.custom path still uses that unbound capture and is fixed separately in #33343.
CI status: red on unrelated lanes, diff is green
Same code, different results, so none of it is reproducible failure. #68435
#68420
This diff is three Local verification on the readline tests: I've used my one re-roll (the empty commit), so I'm not pushing another. Ready for review. |
|
Closing: the readline fix landed separately. #31827 (merged 2026-07-24) replaced Verified on current main (bdb7382): The one remaining piece, the unbound |
Repro
node prints
ABORT_ERR. Bun throws synchronously before.catchis ever attached:Passing an already-aborted signal is the normal shape of cancellation-aware code (a request cancelled before the prompt is reached), and an async API that throws synchronously takes the caller down with it.
Cause
src/js/node/readline.tscapturedPromise.rejectdetached from its receiver:Promise.rejectbuilds its result withNewPromiseCapability(this), so calling it bare leavesthisundefined and it throws|this| is not an object. The two call sites are the already-aborted fast paths ofInterface.prototype.question[util.promisify.custom]and thenode:readline/promisesInterface#question. A signal that aborts later goes throughnew Promise(...)/$newPromiseCapability, which is why only the pre-aborted path was broken.Fix
Bind the receiver, matching the ten other
Promise.$reject/$resolve/withResolverscaptures already insrc/js:Two sibling captures had the same shape and are bound in the same commit:
src/js/internal/fs/cp.ts—PromiseRejectinpathExistsRejected, which would replace a realstat()error with the sameTypeError.src/js/internal/primordials.js—PromiseAll, which madeSafePromiseAllthrow on every call (PromiseResolveon the next line was already bound).Verification
Three cases added to
test/js/node/readline/readline_promises.node.test.ts:node:readline/promisesquestion(), theutil.promisify(rl.question)path, and a signal that aborts after the question was asked. All three match node (AbortError/ABORT_ERR,causepreserved, no prompt written for the pre-aborted case).The first two fail on the unfixed build with
TypeError: |this| is not an objectand pass with the fix.