Skip to content

bun:jsc: filter internal cells out of getProtectedObjects() - #33183

Open
robobun wants to merge 2 commits into
mainfrom
farm/a5396316/jsc-get-protected-objects
Open

bun:jsc: filter internal cells out of getProtectedObjects()#33183
robobun wants to merge 2 commits into
mainfrom
farm/a5396316/jsc-get-protected-objects

Conversation

@robobun

@robobun robobun commented Jul 1, 2026

Copy link
Copy Markdown
Collaborator

What does this PR do?

require("bun:jsc").getProtectedObjects() copies every cell in the heap's protected set into a JS array. That set is not limited to JavaScript objects: at startup it already contains the entry point's UnlinkedProgramCodeBlock, and depending on what the process did it can also hold private symbols, structures, and other engine-internal cells. Those cells are not valid JavaScript values, so the array ends up with elements that report typeof x === "object" but are not objects, and the first generic operation on one of them type-confuses inside JSC.

Repro on Bun 1.4.0 (release):

const arr = require("bun:jsc").getProtectedObjects(); // documented bun:jsc API
arr.map(x => Object.prototype.toString.call(x)); // process aborts, exit code 134

On a debug build the same line asserts:

ASSERTION FAILED: !source || is<Target>(*source)
.WTF/Headers/wtf/TypeCasts.h(120) : match_constness_t<Source, Target> *WTF::downcast(Source *) [Target = JSC::Symbol, Source = const JSC::JSCell]

JSCell::toObject assumes a non-object cell is a string, heap BigInt, or symbol, so for an UnlinkedProgramCodeBlock it falls through to jsCast<Symbol*> on a cell that is not a Symbol. bun:jsc's own describe() shows the two cells protected at startup before this change:

[0] Object: ... (Structure ... GlobalObject ...)
[1] Cell: ... UnlinkedProgramCodeBlock ...

The fix is in functionGetProtectedObjects (src/jsc/modules/BunJSCModule.h): only JSObject cells are appended to the result, which is what the API name promises. Non-object cells are dropped rather than wrapped; exposing them is never safe (private symbols in particular would hand user code the engine's private property names).

How did you verify your code works?

Added getProtectedObjects returns only objects to test/js/bun/jsc/bun-jsc.test.ts. It spawns a child process that calls Object(value) and Object.prototype.toString.call(value) on every element returned by getProtectedObjects().

  • USE_SYSTEM_BUN=1 bun test test/js/bun/jsc/bun-jsc.test.ts -t "returns only objects" fails: the child aborts with exit code 134.
  • bun bd test test/js/bun/jsc/bun-jsc.test.ts passes the new test and the existing getProtectedObjects test (the list is still non-empty, since the global object is protected).

The pre-existing profile can be called multiple times test in the same file exceeds its 5s timeout under a debug ASAN build on the (slow) machine used here; it is unrelated to this change and passes with a release build.


no test proof · iteration 4 · Platform-specific test(s) that do not run on this machine. Deferring to CI, which covers all platforms: test/js/bun/jsc/bun-jsc.test.ts

@coderabbitai

coderabbitai Bot commented Jul 1, 2026

Copy link
Copy Markdown
Contributor

Review Change Stack

Warning

Review limit reached

@robobun, you've reached your PR review limit, so we couldn't start this review.

Next review available in: 3 minutes

Enable usage-based reviews in Billing to review now. Otherwise, wait until the next included review is available.
You're only billed for reviews past your plan's rate limits ($0.25/file).

How can I continue?

After more reviews become available, a review can be triggered using the @coderabbitai review command as a PR comment. Alternatively, push new commits to this PR.

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 configuration

Configuration used: Path: .coderabbit.yaml

Review profile: ASSERTIVE

Plan: Pro

Run ID: eab7ef08-6d94-4d9b-8b54-58c679305500

📥 Commits

Reviewing files that changed from the base of the PR and between b72bbd4 and d2774cd.

📒 Files selected for processing (1)
  • packages/bun-types/jsc.d.ts

Walkthrough

functionGetProtectedObjects now filters protected cells to include only objects, with comments documenting internal non-JavaScript values. A test verifies every returned entry is object-like and that the subprocess completes successfully.

Changes

getProtectedObjects Object Filtering

Layer / File(s) Summary
Filter protected cells and validate returned values
src/jsc/modules/BunJSCModule.h, test/js/bun/jsc/bun-jsc.test.ts
functionGetProtectedObjects appends only cells satisfying cell->isObject(). A subprocess test verifies each returned entry is object-like and checks successful execution output.
🚥 Pre-merge checks | ✅ 4
✅ Passed checks (4 passed)
Check name Status Explanation
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
Title check ✅ Passed The title is concise and accurately summarizes the main change to getProtectedObjects().
Description check ✅ Passed The description fills both required sections and includes what changed plus how it was verified.

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

@robobun

robobun commented Jul 1, 2026

Copy link
Copy Markdown
Collaborator Author
Updated 11:17 PM PT - Jul 10th, 2026

@robobun, your commit d2774cd has 2 failures in Build #71800 (All Failures):


🧪   To try this PR locally:

bunx bun-pr 33183

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

bun-33183 --bun

@github-actions github-actions Bot added the claude label Jul 1, 2026

@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 guard prevents internal JSC cells from leaking into JS as invalid values, with a subprocess test that reproduces the crash.

Extended reasoning...

Overview

This PR adds a single cell->isObject() filter inside functionGetProtectedObjects in src/jsc/modules/BunJSCModule.h, and a subprocess-based regression test in test/js/bun/jsc/bun-jsc.test.ts. Before the change, heap.forEachProtectedCell yielded every protected cell — including engine-internal ones like UnlinkedProgramCodeBlock, private symbols, and structures — and stuffed them raw into a JS array. Those cells are not valid JS values, so the first generic operation on one (e.g. Object.prototype.toString.call) type-confused inside JSC and aborted the process. The fix simply drops non-object cells, which matches the API name and is strictly narrowing.

Security risks

None introduced; if anything this is a hardening. Exposing private symbols to user JS would leak the engine's private property names, and handing out non-JSValue cells is a type-confusion footgun. Filtering to JSObject removes both. There is no new user-controlled input, no allocation change, and no new surface.

Level of scrutiny

Low. bun:jsc is a debugging/introspection module, not a hot production path. The native change is a one-line predicate using a standard JSC accessor (JSCell::isObject()), the semantics are unambiguous, and the change can only remove elements from the result — it cannot introduce wrong ones. The existing getProtectedObjects().length > 0 test still holds because the global object is always protected.

Other factors

The new test follows repo conventions well: it spawns a child via bunExe()/bunEnv, drains stdout/stderr/exited concurrently, asserts a combined { stdout, exitCode } object (avoiding brittle empty-stderr checks), and verifies the list remains non-empty. The PR description confirms the test fails under USE_SYSTEM_BUN=1 (child aborts with 134) and passes on the debug build. The C++ comment is concise and within the 3-line limit. No CODEOWNERS cover this path and there are no outstanding reviewer comments.

@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 isObject() guard on a debugging API, well-tested; the inline nit is diagnostics-only.

Extended reasoning...

Overview

This PR adds a single if (cell->isObject()) guard inside functionGetProtectedObjects in src/jsc/modules/BunJSCModule.h, so bun:jsc's getProtectedObjects() no longer returns engine-internal cells (unlinked code blocks, private symbols, structures) that are not valid JavaScript values. It adds one subprocess test in test/js/bun/jsc/bun-jsc.test.ts that exercises Object(value) and Object.prototype.toString.call(value) on every returned element — the exact operations that abort on the unfixed build.

Security risks

None introduced; if anything this reduces exposure. Before the fix, a caller could receive private-symbol cells from the protected set (which the PR description correctly notes would hand user code engine-private property names). Filtering to JSObject closes that off. The change is strictly a narrowing filter on a read-only introspection API and cannot expose anything new.

Level of scrutiny

Low. bun:jsc is an explicitly-imported debugging/introspection module, not a hot path or a Node/Web compat surface. The change is one predicate inside a heap-iteration lambda, using the canonical JSCell::isObject() check. It cannot break any caller that wasn't already crashing — the only values removed from the array are ones that abort the process on first touch. The pre-existing getProtectedObjects().length > 0 test still passes because the global object remains in the set, and the new test asserts that too (list.length > 0).

Other factors

The PR description traces the crash to JSCell::toObject's fall-through jsCast<Symbol> on a non-string/non-BigInt/non-symbol cell, which is the real mechanism. The author explicitly considered and rejected the alternative of wrapping non-object cells, giving a security rationale (private symbols). The one finding from the bug hunt is a diagnostics nit (assert stderr alongside stdout/exitCode so regression failures are self-diagnosing) — it does not affect test correctness and is not a blocker. No prior human reviews or outstanding comments; CI is building.

Comment thread test/js/bun/jsc/bun-jsc.test.ts Outdated

@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 stderr assertion nit is addressed, and the one-line isObject() filter is the right fix for the crash.

Extended reasoning...

Overview

This PR changes functionGetProtectedObjects in src/jsc/modules/BunJSCModule.h to filter the heap's protected-cell set down to JSObject cells before returning them to JS, and adds a subprocess regression test in test/js/bun/jsc/bun-jsc.test.ts. The native change is a single if (cell->isObject()) guard inside the existing forEachProtectedCell lambda; everything else in the function is untouched.

Security risks

None introduced. The change strictly narrows what is exposed to user JS. The PR description correctly notes that the previous behavior could hand engine-internal cells (unlinked code blocks, private symbols, structures) to userland, which is both a crash vector and — for private symbols — an information-disclosure concern. Filtering to JSObject removes both. JSCell::isObject() is the standard JSC predicate for this and does not run user code, so no new exception-handling is needed.

Level of scrutiny

Low-to-moderate. bun:jsc is a debugging/introspection module, not a hot path, and getProtectedObjects is a diagnostic helper. The fix is defensive (drops elements rather than transforming them), so the worst-case failure mode is an under-populated result array — not a new crash. The existing getProtectedObjects().length > 0 test continues to pass because the global object is always protected and is a JSObject, and the new test verifies every returned element survives Object() and Object.prototype.toString.call().

Other factors

I left one inline nit on the previous revision (include stderr in the combined assertion so a regression's abort message shows in the diff); that was addressed in 909b76c and the thread is resolved. The new test follows the exact subprocess pattern of the three neighboring tests in the same file (bunExe()/bunEnv, piped stdout+stderr drained concurrently, combined-object assertion). The bug-hunting pass on the latest revision found nothing. No CODEOWNERS entry covers this path.

@robobun

robobun commented Jul 1, 2026

Copy link
Copy Markdown
Collaborator Author

CI status: the change and its test are green on every lane that ran them; the failing jobs are unrelated to this diff.

Buildkite build 71800 (head d2774cd):

  • test/js/node/test/parallel/test-worker-message-port-transfer-terminate.js on debian x64-asan: JSC getOwnPropertyDescriptor exception-scope assertion when terminate() races a lazy property builder. Known main issue; being fixed in worker_threads: don't abort when terminate() interrupts a lazy property builder #33418.
  • test/integration/next-pages/test/next-build.test.ts on alpine x64: segfault inside next build. Unrelated to bun:jsc; reported separately.
  • test/js/bun/jsc/bun-jsc.test.ts flaked once on ubuntu aarch64 at line 215 (the pre-existing profile can be called multiple times sampling-profiler assertion) and passed on retry; the new getProtectedObjects returns only objects test is at line 364 and was not the failure.
  • The other yellow entries (update_interactive_install, node-http-connect, fetch-backpressure, spawn, test-repl-close, bun-run-dir, napi, test-fs-promises-file-handle-readFile, no-orphans, net-mongodb-pattern-leak, test-net-write-slow) were auto-retried by the pipeline and passed.

GitHub Actions "TypeScript types": failing on main independently of this PR (ENOENT ... node_modules/typescript/lib/lib.es2020.d.ts; main run); being fixed in #33940. git stash of the jsc.d.ts change reproduces the identical failure locally.

@robobun
robobun force-pushed the farm/a5396316/jsc-get-protected-objects branch from 909b76c to b72bbd4 Compare July 11, 2026 02:58

@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 guard that stops getProtectedObjects() from returning non-JSObject cells and crashing on first use; my earlier stderr-assertion nit was addressed in 909b76c. The remaining inline note is a docs-only nit and doesn't block.

Extended reasoning...

Overview

The PR adds a single if (cell->isObject()) guard inside the forEachProtectedCell lambda in functionGetProtectedObjects (src/jsc/modules/BunJSCModule.h), plus a subprocess regression test in test/js/bun/jsc/bun-jsc.test.ts. Before the change, internal JSC cells (UnlinkedProgramCodeBlock, private symbols, structures) were placed into the returned JS array; touching one via Object.prototype.toString.call fell through JSCell::toObject's string/BigInt/Symbol switch into a bad jsCast<Symbol*>, aborting the process.

Security risks

None introduced. The change strictly narrows what is exposed to JS. If anything it is a mild hardening: private symbols in the protected set are no longer handed to user code.

Level of scrutiny

Low-to-moderate. bun:jsc is a diagnostic/debug module, not a hot production path. The fix is a well-understood JSC idiom (JSCell::isObject() gates on the JSType range for JSObject), and the semantics match the API name. The pre-existing getProtectedObjects().length > 0 test still passes because the global object is always protected, and the new test independently asserts list.length > 0.

Other factors

  • My prior inline comment (assert {stdout, stderr, exitCode}) was addressed in 909b76c and the thread is resolved.
  • CI: test/js/bun/jsc/bun-jsc.test.ts passed on every lane in builds 67553 and 67578; the failing jobs are known flakes unrelated to this diff.
  • The one finding from this run is a stale JSDoc warning in packages/bun-types/jsc.d.ts — docs-only, no runtime effect, fine to address as a follow-up.
  • I considered whether filtering also drops legitimately-protected strings/BigInts/public Symbols that are valid JS values: it does, but the function is named getProtectedObjects, and the PR argues (correctly) that private symbols must not leak, so filtering to objects is the conservative name-matching choice.

Comment thread src/jsc/modules/BunJSCModule.h
@robobun
robobun requested a review from alii as a code owner July 11, 2026 03:05

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

Looks good — the cell->isObject() filter is the right fix and both earlier nits are addressed. Not auto-approving only because packages/bun-types/ has a CODEOWNER; the .d.ts change is a trivial JSDoc reword + any[]object[] tightening.

What was reviewed:

  • forEachProtectedCell lambda now skips non-object cells; MarkedArgumentBuffer still roots survivors across constructArray.
  • Confirmed the existing getProtectedObjects().length > 0 test still holds (global object is always protected), and the new subprocess test exercises Object() / toString.call on every element.
  • object[] return type matches the post-filter contract; no callers in-tree depend on the old any[].
Extended reasoning...

Overview

Three files: a one-condition filter in functionGetProtectedObjects (src/jsc/modules/BunJSCModule.h) so only JSCells where isObject() is true are appended to the returned array; a subprocess regression test in test/js/bun/jsc/bun-jsc.test.ts; and a JSDoc/return-type update in packages/bun-types/jsc.d.ts. The last file was added in d2774cd in response to my previous review comment.

Security risks

None introduced. The change strictly narrows what is exposed to JS — previously the array could contain engine-internal cells (unlinked code blocks, private symbols, structures) that type-confused on first touch and, in the private-symbol case, would have leaked engine-private property names. Dropping them is a strict reduction in attack surface. bun:jsc is a debugging module, not on any untrusted-input path.

Level of scrutiny

Low-to-medium. The C++ change is a single well-understood predicate (JSCell::isObject()) inside an existing iteration; no new allocation, no new GC interaction (the MarkedArgumentBuffer was already there). The API is documented as a Bun-internal debugging tool. The pre-existing getProtectedObjects().length > 0 test guards against over-filtering.

Other factors

Both of my earlier inline nits (assert stderr in the combined object; update the stale JSDoc warning) were addressed in 909b76c and d2774cd respectively. CI on 909b76c passed bun-jsc.test.ts on every lane per the author's build summary; the d2774cd commit is types-only. The one thing keeping me from auto-approving is that packages/bun-types/ is CODEOWNER-gated, and the return-type tightening (any[]object[]) — while accurate and low-risk for a debug-only API — is a public type surface change that owner should sign off on.

@robobun

robobun commented Jul 11, 2026

Copy link
Copy Markdown
Collaborator Author

The "TypeScript types" check failure is not caused by this PR's jsc.d.ts edit: test/integration/bun-types/bun-types.test.ts currently fails on main with the same ENOENT ... node_modules/typescript/lib/lib.es2020.d.ts error (main run), and git stash-ing the jsc.d.ts change reproduces the identical failure locally. The workflow only triggers on changes under packages/bun-types/, so this PR surfaced it. It is being fixed separately in #33940.

Jarred-Sumner pushed a commit that referenced this pull request Jul 15, 2026
…not fixed delays (#33983)

"should handle partial writes and buffering" in
`node-http-connect.node.mts` wrote `"Client data"` from the client at a
fixed `t=35ms` while the server wrote `"Test data"`+`end()` at `t=40ms`
relative to its own `'connect'` event. Under CPU load on Windows the
client's 35ms timer fires late enough that the server's FIN reaches the
client first, the awaited promise resolves on the client's `'end'`, and
the assertion on `bufferReceived` runs before the server's data handler
has seen the bytes.

Seen red on Windows 2019 x64-baseline in two unrelated PR builds:
[71915](https://buildkite.com/bun/bun/builds/71915) (#33974, epoll-only
change) and [71800](https://buildkite.com/bun/bun/builds/71800) (#33183,
a types-only change), both with:

```
AssertionError: false == true
      at toContain (...\node-http-connect.node.mts:18:14)
      at ...\node-http-connect.node.mts:351:28
(fail) HTTP server CONNECT > should handle partial writes and buffering
```

Reproduced locally on Windows with 16 background spinners: 8/50 runs
fail. After this change, 0/100 under the same load.

The test no longer uses `setTimeout`. A `writeChunked` helper writes
each chunk, awaits its write callback, then yields one `setImmediate` so
the peer still sees a fragmented stream (verified: 1 `'data'` event with
a bare write-callback chain vs 3 with this helper, both Node and Bun).
The client sends `"Client data"` once it has seen the full `"Connection
established"` response, the server ends only once it has received it
(from the `"Test data"` write callback so the FIN cannot outrun it), and
both sockets have `'error'` wired to reject the awaited promise. Passes
under Node and Bun on Linux and Windows.

The test landed in #22756 and has had this race since then.

<!-- robobun:evidence:begin -->

---

**no test proof** · iteration 1 · docs-only change; test-proof not
applicable

<!-- robobun:evidence:end -->
@robobun

robobun commented Aug 12, 2026

Copy link
Copy Markdown
Collaborator Author

Still reproduces on current main (9a543cc) and on the 1.4.0 canary: getProtectedObjects() returns the global object plus an UnlinkedProgramCodeBlock cell at startup, and Object.prototype.toString.call() on the second element aborts the process (panic(main thread): abort() called; debug builds assert !source || is<Target>(*source) in downcast<JSC::Symbol>).

This branch needs a rebase: #35849 changed functionGetProtectedObjects to also walk the StrongRootBlock cells behind bun_jsc::Strong (the forEachOccupiedCell loop), which is where the conflict is. Those slots only ever hold values that came in as JSValues through Bun__StrongRef__new, so they cannot contain the internal cells that cause the abort, but a Strong can hold a string or symbol, so that loop should get the same isObject() filter to keep the object[] return type this PR introduces accurate.

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.

1 participant