Skip to content

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

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

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

@robobun robobun commented May 17, 2026

Copy link
Copy Markdown
Collaborator

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

@robobun

robobun commented May 17, 2026

Copy link
Copy Markdown
Collaborator Author
Updated 7:51 PM PT - Jun 18th, 2026

@robobun, your commit 40547c5 has 2 failures in Build #63426 (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

coderabbitai Bot commented May 17, 2026

Copy link
Copy Markdown
Contributor

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.

@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 — 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).

@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 — 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

@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 — 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

robobun commented May 17, 2026

Copy link
Copy Markdown
Collaborator Author

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.

robobun added 3 commits June 19, 2026 02:01
…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
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`.
@robobun
robobun force-pushed the farm/f717b30d/fix-safe-promise-all-unbound branch from 3420117 to 40547c5 Compare June 19, 2026 02:06
@robobun

robobun commented Jun 19, 2026

Copy link
Copy Markdown
Collaborator Author

Rebased onto main (past #31830, which refactored fs/cp.ts). The only conflict was in cp.ts: #31830 moved the cp helpers into internal/fs/cp-sync but kept const PromiseReject = Promise.$reject; unbound. Resolved by taking main's refactored file and re-applying the one-line .bind(Promise) fix (the pathExistsRejected call site at line 153 is unchanged in behavior). primordials.js and readline.ts merged cleanly.

Re-verified after rebase: readline_promises.node.test.ts + node-stream.test.js (69 pass, incl. both new tests), cp.test.ts + cp-symlink-target.test.ts (42 pass).

@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 — the two extra .bind(Promise) spots from my earlier comment are swept up and the rebase looks clean.

Extended reasoning...

Overview

Three one-line source changes adding .bind(Promise) to Promise static method references that are later invoked as free functions: Promise.all in src/js/internal/primordials.js, and Promise.$reject in src/js/internal/fs/cp.ts and src/js/node/readline.ts. Two regression tests added (Transform.fromWeb _writev path, and readline/promises question() with a pre-aborted signal).

Security risks

None. This only changes the this binding of internal Promise static references; no auth, crypto, permissions, or external input handling is affected.

Level of scrutiny

Low. The fix is mechanical and matches the established pattern already used on the adjacent line (PromiseResolve = Promise.$resolve.bind(Promise)) and elsewhere in the codebase (operators.ts, diagnostics_channel.ts). Binding a Promise static to Promise is strictly safe — these builtins require this to be a constructor, so the change can only fix throws, not introduce new behavior.

Other factors

My earlier inline comment flagging the two additional unbound Promise.$reject sites was addressed in 3420117 with a regression test, and that thread is resolved. The PR was rebased past #31830 with a trivial conflict resolution in cp.ts (re-applying the same one-liner to the refactored file), and targeted tests were re-verified post-rebase. The bug-hunting system found no issues. No outstanding reviewer comments remain.

@robobun

robobun commented Jun 19, 2026

Copy link
Copy Markdown
Collaborator Author

Thanks for the re-review. Rebase is clean and all three .bind(Promise) fixes plus both regression tests are intact on the new base.

@robobun

robobun commented Jun 19, 2026

Copy link
Copy Markdown
Collaborator Author

Rebased-build CI (#63426, sha 40547c5f) is green except for unrelated flakes:

  • darwin 26 aarch64 - test-bun (the only hard failure): SIGTRAP in test/js/third_party/grpc-js/test-server.test.ts. That test imports none of this PR's changed files, and grpc-js passes on main (build #63422, the same rebase base). Adding .bind(Promise) to a JS Promise static cannot produce a native SIGTRAP.
  • hot.test.ts and bun-install.test.ts (Windows 2019 x64): marked flaky by Buildkite, passed on retry (stale-sourcemap path assertion and an EBADF fstat, both Windows-specific, neither in this diff).

This PR's three one-line .bind(Promise) fixes and two regression tests are unaffected. Re-verified locally post-rebase: readline_promises.node.test.ts + node-stream.test.js (69 pass), cp.test.ts + cp-symlink-target.test.ts (42 pass). Diff is green on every lane that touches the changed code; needs a maintainer to merge.

@robobun

robobun commented Jul 5, 2026

Copy link
Copy Markdown
Collaborator Author

Superseded by #33343, which carries the same three .bind(Promise) changes (src/js/node/readline.ts, src/js/internal/fs/cp.ts, src/js/internal/primordials.js) rebased onto current main.

The regression test in this PR no longer exercises the fix. #31991 changed the three webstreams_adapters.ts call sites from SafePromiseAll to SafePromiseAllReturnVoid, which routes through safePromiseAllCollect and never touches PromiseAll. So the Transform.fromWeb repro from #30939 now passes on a canary that does not have the primordials.js fix:

$ bun --revision
1.4.0-canary.1+1498d7b77
$ bun repro.cjs
hellohellohellohellohello

#33343 keeps the same source fix and tests it through the path that is still broken on main: rl.question() with an already-aborted AbortSignal throws TypeError: |this| is not an object synchronously instead of returning a rejected promise. Those tests do fail on an unfixed build.

Closing in favor of #33343.

@robobun robobun closed this Jul 5, 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.

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

1 participant