Skip to content

Bind Promise.all in primordials so SafePromiseAll works as a free function#30940

Open
robobun wants to merge 3 commits into
mainfrom
farm/f717b30d/fix-safe-promise-all-unbound
Open

Bind Promise.all in primordials so SafePromiseAll works as a free function#30940
robobun wants to merge 3 commits into
mainfrom
farm/f717b30d/fix-safe-promise-all-unbound

Conversation

@robobun
Copy link
Copy Markdown
Collaborator

@robobun robobun commented May 17, 2026

Repro

const { TransformStream } = require('stream/web');
const { Transform } = require('stream');
const s = Transform.fromWeb(new TransformStream());
s.pipe(process.stderr);
s.write(Buffer.from('hello'));
s.write(Buffer.from('hello'));
s.write(Buffer.from('hello'));
s.end();

Bun threw TypeError: |this| is not an object inside internal:webstreams_adapters.

Cause

src/js/internal/primordials.js captured Promise.all unbound:

const PromiseAll = Promise.all;

SafePromiseAll calls PromiseAll(...) as a free function — so its this is undefined, and Promise.all's internal NewPromiseCapability(this, …) throws.

Consumers: the Transform.fromWeb / Duplex.fromWeb _writev path and closeWriter/closeReader in webstreams_adapters.ts (lines 323, 621, 721). The 5-sync-write repro forces the writable side to queue and coalesce into a single _writev call.

Fix

Bind it to Promise, matching the existing pattern on the next line for PromiseResolve:

-const PromiseAll = Promise.all;
+const PromiseAll = Promise.all.bind(Promise);
 const PromiseResolve = Promise.$resolve.bind(Promise);

Verification

USE_SYSTEM_BUN=1 bun test test/js/node/stream/node-stream.test.js -t "Transform.fromWeb _writev"  → FAIL (TypeError)
bun bd test test/js/node/stream/node-stream.test.js -t "Transform.fromWeb _writev"               → PASS
bun bd test test/js/node/stream/node-stream.test.js                                               → 37 pass, 0 fail

Fixes #30939

…alone

Transform.fromWeb's _writev invokes SafePromiseAll as a free function. Since
PromiseAll was captured as `Promise.all` without binding, the call's `this`
was undefined and Promise.all's NewPromiseCapability threw 'TypeError: |this|
is not an object'.

Fixes #30939
@robobun
Copy link
Copy Markdown
Collaborator Author

robobun commented May 17, 2026

Updated 3:59 PM PT - May 17th, 2026

@robobun, your commit 3420117 has 1 failures in Build #55574 (All Failures):


🧪   To try this PR locally:

bunx bun-pr 30940

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

bun-30940 --bun

@coderabbitai
Copy link
Copy Markdown
Contributor

coderabbitai Bot commented May 17, 2026

Review Change Stack

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: ASSERTIVE

Plan: Pro

Run ID: 2b75c303-6896-4f96-92dd-cef362d65450

📥 Commits

Reviewing files that changed from the base of the PR and between f13678b and 3420117.

📒 Files selected for processing (3)
  • src/js/internal/fs/cp.ts
  • src/js/node/readline.ts
  • test/js/node/readline/readline_promises.node.test.ts

Walkthrough

Binds internal Promise helpers (Promise.all, Promise.$reject) to Promise and adds regression tests: one for Transform.fromWeb buffered writes and one for readlinePromises.Interface#question() with a pre-aborted AbortSignal.

Changes

Promise binding fixes and regression tests

Layer / File(s) Summary
Bind internal Promise helpers
src/js/internal/primordials.js, src/js/internal/fs/cp.ts, src/js/node/readline.ts
Convert unbound references (Promise.all, Promise.$reject) to bound functions (Promise.all.bind(Promise), Promise.$reject.bind(Promise)) for internal helper usage.
Transform.fromWeb buffered writes regression test
test/js/node/stream/node-stream.test.js
Adds a test that writes five synchronous Buffer.from("hello") calls to a Transform.fromWeb stream, ensuring coalesced _writev handling does not throw and the concatenated output matches five repetitions.
readlinePromises.question pre-aborted AbortSignal test
test/js/node/readline/readline_promises.node.test.ts
Adds a test asserting readlinePromises.Interface#question() rejects with an AbortError when invoked with an already-aborted AbortSignal.
🚥 Pre-merge checks | ✅ 4
✅ Passed checks (4 passed)
Check name Status Explanation
Title check ✅ Passed The title accurately reflects the main change: binding Promise.all in primordials to enable SafePromiseAll to work as a free function, which is the core issue being fixed.
Description check ✅ Passed The description follows the template with clear sections for 'Repro', 'Cause', 'Fix', and 'Verification', providing comprehensive context for the changes.
Linked Issues check ✅ Passed The PR directly addresses the root cause in issue #30939 by binding Promise.all to fix the TypeError when SafePromiseAll is called as a free function, and includes regression tests.
Out of Scope Changes check ✅ Passed Changes include Promise binding in primordials and fs/cp.ts/readline.ts following the same pattern, plus regression tests for Transform.fromWeb and readline—all directly aligned with fixing the unbound Promise method issue.

✏️ 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.

Copy link
Copy Markdown
Contributor

@claude claude Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

LGTM — one-line fix binding Promise.all to Promise, matching the existing PromiseResolve pattern on the next line, with a regression test.

Extended reasoning...

Overview

This PR changes a single line in src/js/internal/primordials.js: const PromiseAll = Promise.all;const PromiseAll = Promise.all.bind(Promise);. It also adds a regression test in test/js/node/stream/node-stream.test.js that exercises the Transform.fromWeb _writev path which calls SafePromiseAll.

Correctness

Promise.all requires its this to be a constructor per spec (it calls NewPromiseCapability(this)). SafePromiseAll invokes PromiseAll(...) as a free function, so this is undefined and JSC throws TypeError: |this| is not an object. Binding to Promise is the obvious, correct fix and exactly mirrors the pattern already used for PromiseResolve = Promise.$resolve.bind(Promise) on the immediately following line. PromiseAll is only referenced once in this file (inside SafePromiseAll), so there's no risk of changing other call sites.

Security risks

None. This is internal runtime glue code; binding a built-in method to its constructor introduces no new attack surface.

Level of scrutiny

Low. One-character-of-intent bug fix following an established adjacent pattern, with a clear repro, root-cause explanation, and a passing regression test. No design decisions or behavioral ambiguity.

Other factors

No bugs flagged by the bug-hunting system, no outstanding reviewer comments, and the test verifies end-to-end behavior (buffered writes through Transform.fromWeb produce the expected output rather than throwing).

Copy link
Copy Markdown
Contributor

@claude claude Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

LGTM — one-line fix that matches the existing PromiseResolve binding pattern on the next line, with a regression test.

Extended reasoning...

Overview

This PR changes a single line in src/js/internal/primordials.js, replacing const PromiseAll = Promise.all with const PromiseAll = Promise.all.bind(Promise). The only consumer is SafePromiseAll two lines below, which calls PromiseAll(...) as a free function — without a bound receiver, JSC's Promise.all throws TypeError: |this| is not an object at NewPromiseCapability(this). The fix is identical in form to the immediately following line, const PromiseResolve = Promise.$resolve.bind(Promise), so it follows an established pattern in the same file. A regression test in test/js/node/stream/node-stream.test.js exercises the Transform.fromWeb_writevSafePromiseAll path that triggered the bug.

Security risks

None. This is an internal primordials helper; binding Promise.all to Promise has no security implications and does not change which capabilities are exposed.

Level of scrutiny

Low. The change is mechanical, mirrors the adjacent line, and the failure mode it fixes is well-documented in the PR description with a before/after test run. The added test is straightforward and self-contained (uses the global TransformStream, which is available in Bun's runtime). No CODEOWNERS apply to either modified file.

Other factors

The one inline comment posted alongside this review flags two pre-existing instances of the same unbound-Promise-static pattern in fs/cp.ts and readline.ts. Those are not introduced or affected by this PR and can be handled in a follow-up; they don't block this fix. The robobun CI comment shows failures on the first commit, but a ci: retrigger commit was pushed afterward and the change itself cannot plausibly cause unrelated test failures.

Comment thread src/js/internal/primordials.js
Same pattern as the primordials fix — `PromiseReject` was captured unbound,
so calling it as a free function invoked `Promise.$reject` with `this ===
undefined` and threw `TypeError: |this| is not an object`, masking the real
rejection reason.

- readline: `rl.question(q, { signal })` with a pre-aborted signal now
  correctly rejects with AbortError instead of TypeError.
- fs/cp: `pathExists` now correctly propagates non-ENOENT stat errors
  (e.g. EACCES on destParent) instead of replacing them with TypeError.

Both call sites mirror the already-bound `PromiseReject` in
`diagnostics_channel.ts` and `internal/streams/operators.ts`.
Copy link
Copy Markdown
Contributor

@claude claude Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

LGTM — thanks for sweeping up the two extra unbound Promise.$reject spots and adding the readline regression test.

Extended reasoning...

Overview

Three one-line source changes, each adding .bind(Promise) to a captured Promise static so it can be called as a free function:

  • src/js/internal/primordials.js:96Promise.allPromise.all.bind(Promise) (the original #30939 fix; PromiseResolve on the next line already used this pattern)
  • src/js/internal/fs/cp.ts:21Promise.$rejectPromise.$reject.bind(Promise)
  • src/js/node/readline.ts:46 — same

Plus two regression tests: Transform.fromWeb buffered writes hitting _writev/SafePromiseAll, and readline/promises question() with a pre-aborted signal.

Security risks

None. No input parsing, auth, crypto, or permission logic is touched — this only changes the receiver binding of Promise statics in internal primordial captures.

Level of scrutiny

Low. The fix is mechanical and matches the established idiom already used at primordials.js:97 (PromiseResolve), internal/streams/operators.ts, and node/diagnostics_channel.ts. Each change strictly widens correctness (previously these threw TypeError: |this| is not an object on the affected paths; now they behave as intended). There is no plausible regression surface from binding a static to its own constructor.

Other factors

The two extra .bind(Promise) fixes in cp.ts/readline.ts were the follow-ups I flagged in my earlier review; the author applied them in 3420117 and the inline thread is resolved. Both new tests are self-contained, deterministic, and directly exercise the previously-broken paths. No CODEOWNERS apply to these files. The robobun CI comment references an earlier commit (6a68f58) before the follow-up was pushed, so it doesn't reflect the current head.

@robobun
Copy link
Copy Markdown
Collaborator Author

robobun commented May 17, 2026

CI lane ubuntu-25-dot-04-x64-test-bun (shard 019e37b9-3c11) failed on build #55574 — not caused by this diff. The failing shard contains readline_never_unrefs and stdin-pause-pty tests which fail identically on main with bun bd (pre-existing ASAN/debug-build flakes):

git checkout main -- src/ test/ && bun bd test test/js/node/readline/readline_never_unrefs.test.ts
→ fail: exit code null (1 second timeout)

All other lanes passed, including linux-x64-asan-test-bun and every other ubuntu-25 shard. The three files this PR changes (primordials.js, cp.ts, readline.ts) have passing targeted test runs — readline_promises.node.test.ts (4 pass), cp.test.ts + cp-symlink-target.test.ts (42 pass), node-stream.test.js (42 pass).

Already spent my one ci: retrigger on build #55566. Needs a maintainer to merge.

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.

stream/web TransformStream + Node Transform.fromWeb causes internal webstreams_adapters crash (this is not an object)

1 participant