Skip to content

util: styleText handles inspect.colors added after first call; getCallSites captures Error - #35409

Open
robobun wants to merge 3 commits into
mainfrom
farm/7fc3c174/util-styletext-cache-globals
Open

util: styleText handles inspect.colors added after first call; getCallSites captures Error#35409
robobun wants to merge 3 commits into
mainfrom
farm/7fc3c174/util-styletext-cache-globals

Conversation

@robobun

@robobun robobun commented Jul 24, 2026

Copy link
Copy Markdown
Collaborator

What

Two follow-up fixes to #34434 in src/js/node/util.ts.

styleText: cache miss on user-added color

const util = require("node:util");
util.styleText("red", "x", { validateStream: false }); // populates cache
util.inspect.colors.myColor = [95, 39];
util.styleText("myColor", "x", { validateStream: false });
// TypeError: undefined is not an object (evaluating 'style.openSeq')

getStyleCache() snapshots inspect.colors once, but the unknown-key path validates against the live ObjectGetOwnPropertyNames(inspect.colors) and then unconditionally reads style.openSeq. A color added after the cache was built passes validation and then crashes. Before #34434 the lookup was always live so user-added colors worked.

Fix: on cache miss, read the live inspect.colors[key] and build/cache the entry. Invalid names still hit validateOneOf and throw ERR_INVALID_ARG_VALUE; mutated or deleted built-in entries continue to resolve from the cache, matching Node v26.3.0.

getCallSites: bare global Error

const util = require("node:util");
globalThis.Error = undefined;
util.getCallSites(5);
// TypeError: undefined is not an object

Node's implementation goes through an internal binding and is unaffected by the Error global. We implement it over Error.prepareStackTrace/captureStackTrace, so replacing globalThis.Error broke it. Capture Error at module load, the same pattern child_process.ts already uses.

The Object.freeze(Error) case still throws because we must assign Error.prepareStackTrace; matching Node there would need a native call-site binding.

The handoff also flagged the Buffer.from call in hexToRgb, but Buffer is already in the codegen globalsToPrefix list and is rewritten to the private intrinsic at build time, so it is unaffected by globalThis.Buffer tampering (verified).

Tests

Added two subprocess tests in test/js/node/util/util.test.js covering the added-color path and the replaced-Error path. Both fail on main and pass with this change; the rest of the file (210 tests) and test-util-getcallsites-preparestacktrace.js continue to pass.


[review] gate passed · iteration 1 · 2 files touched

fails on main (without fix)
ASAN without fix: 2 FAILED
$ BUN_DEBUG_QUIET_LOGS=1 bun scripts/build.ts --profile=debug --quiet test "--reporter=junit" "--reporter-outfile=/tmp/mechgate.xml" test/js/node/util/util.test.js
bun test v1.4.0 (8222b8617)

test/js/node/util/util.test.js:
(pass) util > toUSVString [4.60ms]
(pass) util > inherits [5.35ms]
(pass) util > isArray > all cases [7.02ms]
(pass) util > isRegExp > all cases [3.75ms]
(pass) util > isDate > all cases [155.48ms]
(pass) util > isError > all cases [15.33ms]
(pass) util > isObject > all cases [3.92ms]
(pass) util > isPrimitive > all cases [8.11ms]
(pass) util > isBuffer > all cases [3.40ms]
(pass) util > _extend > all cases [7.65ms]
(pass) util > isBoolean > all cases [2.69ms]
(pass) util > isNull > all cases [2.83ms]
(pass) util > isUndefined > all cases [2.88ms]
(pass) util > isNullOrUndefined > all cases [2.75ms]
(pass) util > isNumber > all cases [2.79ms]
(pass) util > isString > all cases [2.54ms]
(pass) util > isSymbol > all cases [2.58ms]
(pass) util > isFunction > all cases [3.08ms]
(pass) util > types.isNativeError > all cases [4.28ms]
(pass) util > TextEncoder > is same as global TextEncoder [1.21ms]
(pass) util > TextDecoder > is same as global TextDe
... (truncated)

release without fix: all passed
bun test v1.4.0-canary.1 (38c513d34)

test/js/node/util/util.test.js:
(pass) util > toUSVString [0.10ms]
(pass) util > inherits [0.12ms]
(pass) util > isArray > all cases [0.10ms]
(pass) util > isRegExp > all cases [0.05ms]
(pass) util > isDate > all cases [5.43ms]
(pass) util > isError > all cases [0.23ms]
(pass) util > isObject > all cases [0.05ms]
(pass) util > isPrimitive > all cases [0.12ms]
(pass) util > isBuffer > all cases [0.04ms]
(pass) util > _extend > all cases [0.09ms]
(pass) util > isBoolean > all cases [0.02ms]
(pass) util > isNull > all cases [0.02ms]
(pass) util > isUndefined > all cases [0.04ms]
(pass) util > isNullOrUndefined > all cases [0.02ms]
(pass) util > isNumber > all cases [0.02ms]
(pass) util > isString > all cases [0.02ms]
(pass) util > isSymbol > all cases [0.02ms]
(pass) util > isFunction > all cases [0.03ms]
(pass) util > types.isNativeError > all cases [0.04ms]
(pass) util > TextEncoder > is same as global TextEncoder [0.01ms]
(pass) util > TextDecoder > is same as global TextDecoder [0.01ms]
(pass) util > format [0.22ms]
(pass) util > formatWithOptions [1.45ms]
(pass) util > styleText hex colors > parses 6-digit hex [0.52ms]
(pass) 
... (truncated)
passes on PR (with fix)
ASAN with fix: all passed
$ BUN_DEBUG_QUIET_LOGS=1 bun scripts/build.ts --profile=debug --quiet test "--reporter=junit" "--reporter-outfile=/tmp/mechgate.xml" test/js/node/util/util.test.js
bun test v1.4.0 (8222b8617)

test/js/node/util/util.test.js:
(pass) util > toUSVString [4.49ms]
(pass) util > inherits [5.04ms]
(pass) util > isArray > all cases [7.03ms]
(pass) util > isRegExp > all cases [4.01ms]
(pass) util > isDate > all cases [154.51ms]
(pass) util > isError > all cases [14.94ms]
(pass) util > isObject > all cases [3.93ms]
(pass) util > isPrimitive > all cases [8.51ms]
(pass) util > isBuffer > all cases [3.48ms]
(pass) util > _extend > all cases [7.74ms]
(pass) util > isBoolean > all cases [2.62ms]
(pass) util > isNull > all cases [2.87ms]
(pass) util > isUndefined > all cases [2.87ms]
(pass) util > isNullOrUndefined > all cases [2.74ms]
(pass) util > isNumber > all cases [2.87ms]
(pass) util > isString > all cases [2.58ms]
(pass) util > isSymbol > all cases [2.54ms]
(pass) util > isFunction > all cases [3.07ms]
(pass) util > types.isNativeError > all cases [4.36ms]
(pass) util > TextEncoder > is same as global TextEncoder [1.17ms]
(pass) util > TextDecoder > is same as global TextDe
... (truncated)

release with fix: all passed
$ bun scripts/build.ts --profile=release
[configured] bun-profile → bun (stripped) in 634ms (unchanged)
ninja: Entering directory `/workspace/bun/build/release'
[1/21] gen JS modules (bundle-modules)
Preprocess modules (7441ms)
Bundle modules (44ms)
Postprocesss modules (120ms)
Bundle Functions (662ms)
Generate Code (14ms)

[8.30s] Bundled "src/js" for production
  2113 kb
  167 internal modules
  13 native modules
  90 internal functions across 19 files
[1/5] cargo bun_bin → libbun_rust.a (--target x86_64-unknown-linux-gnu)

  nightly-2026-07-20-x86_64-unknown-linux-gnu unchanged - rustc 1.99.0-nightly (9f36de775 2026-07-19)

�[1m�[92m    Blocking�[0m waiting for file lock on build directory
�[1m�[92m    Finished�[0m `release` profile [optimized + debuginfo] target(s) in 19.57s
[2/5] link bun-profile
[3/5] bun-profile --revision
1.4.0-canary.1+8222b8617
[5/5] strip bun
[build] done
bun test v1.4.0-canary.1 (8222b8617)

test/js/node/util/util.test.js:
(pass) util > toUSVString [0.10ms]
(pass) util > inherits [0.12ms]
(pass) util > isArray > all cases [0.12ms]
(pass) util > isRegExp > all cases [0.05ms]
(pass) util > isDate > all cases [5.70ms]
(pass) util > isEr
... (truncated)
diff hotspot
src/js/node/util.ts            | 35 ++++++++++++++++++++----------
 test/js/node/util/util.test.js | 49 +++++++++++++++++++++++++++++++++++++++++-
 2 files changed, 72 insertions(+), 12 deletions(-)

gate history · 2 passed · 0 rejected · iteration 1

evidence per changed file
file                            reads  edits  tests
src/js/node/util.ts                 3      6      0
test/js/node/util/util.test.js      4      4      0

…ites captures Error

styleText() caches inspect.colors on first call but validated unknown
format strings against the live inspect.colors, so a color added after
the cache was populated passed validation then dereferenced an undefined
cache entry. Fall back to the live inspect.colors entry on cache miss
and populate the cache, restoring the behavior from before the cache was
introduced.

getCallSites() read Error via the global binding, so replacing
globalThis.Error broke it. Capture Error at module load like
child_process.ts already does.
@robobun

robobun commented Jul 24, 2026

Copy link
Copy Markdown
Collaborator Author
Updated 6:50 AM PT - Jul 24th, 2026

@robobun, your commit 8222b861764acc5ae46e825e9113d444fc585ed3 passed in Build #79522! 🎉


🧪   To try this PR locally:

bunx bun-pr 35409

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

bun-35409 --bun

@robobun

robobun commented Jul 24, 2026

Copy link
Copy Markdown
Collaborator Author

Status: diff is green. Gate (fail-before/pass-after on ASAN and release) passed, test/js/node/util/util.test.js passes on all lanes, bot reviews LGTM.

CI is red on unrelated flaky tests that this diff doesn't touch (terminal ConPTY on Windows, install registry hoisting on win-aarch64, security-scanner snapshot on Alpine, multi-run on darwin). Retriggered once with the same pattern on different tests. Ready for a maintainer to merge.

@coderabbitai

coderabbitai Bot commented Jul 24, 2026

Copy link
Copy Markdown
Contributor

Review Change Stack

Walkthrough

Changes

Node util runtime behavior

Layer / File(s) Summary
Dynamic style cache handling
src/js/node/util.ts, test/js/node/util/util.test.js
Style entries are constructed through a helper, lazily cached for newly added inspect.colors entries, and covered by subprocess tests.
Stable getCallSites Error binding
src/js/node/util.ts, test/js/node/util/util.test.js
The module binds globalThis.Error locally, with coverage verifying getCallSites after globalThis.Error is unset.

Possibly related PRs

  • oven-sh/bun#34434: Overlapping changes to styleText, style caching, and getCallSites handling.

Suggested reviewers: cirospaciari

🚥 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 clearly summarizes the two util.ts fixes and matches the changeset.
Description check ✅ Passed The description covers what changed and how it was verified, with concrete tests and results.

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

@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 current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@test/js/node/util/util.test.js`:
- Around line 452-458: Update the subprocess assertions in
test/js/node/util/util.test.js at lines 452-458 and 491-494: keep draining
stderr but remove exact empty-stderr assertions, and combine the parsed stdout
result with exitCode in a single toEqual assertion for the style test and
getCallSites() test respectively.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: ASSERTIVE

Plan: Pro

Run ID: 8c7ed520-f688-43bb-bc66-120047785826

📥 Commits

Reviewing files that changed from the base of the PR and between df84f8d and 4e609e8.

📒 Files selected for processing (2)
  • src/js/node/util.ts
  • test/js/node/util/util.test.js

Comment thread test/js/node/util/util.test.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 — targeted follow-up to #34434 fixing two real regressions. The inline nit (null-valued inspect.colors entry falling through to buildStyleEntry(null)) is not a regression and is a garbage-in edge case; fine to address or leave.

Extended reasoning...

Overview

Two small fixes in src/js/node/util.ts plus two subprocess tests:

  1. styleText: on cache miss, look up inspect.colors[key] live and cache the built entry (factored into buildStyleEntry), instead of validating-then-crashing on style.openSeq. Restores pre-#34434 behavior for user-added colors.
  2. getCallSites: capture Error at module load via var Error = globalThis.Error, matching the existing pattern in src/js/node/child_process.ts:1919, so replacing globalThis.Error doesn't break it.

Security risks

None. No untrusted-input parsing, no auth/crypto/permissions. inspect.colors has __proto__: null (inspect.js:760), so the new inspect.colors[key] read is not a prototype-pollution vector. Error is not in globalsToPrefix, so the module-level var Error shadow is the correct hardening approach and doesn't collide with codegen rewriting.

Level of scrutiny

Low-to-medium. Small, mechanical follow-up to a recent PR; both fixes restore prior behavior. The buildStyleEntry extraction is a pure refactor of the loop body. The only other Error reference in the file (callbackifyOnRejected's new Error(...)) is unaffected by the shadow.

Other factors

  • Tests follow harness conventions: it.concurrent, bunEnv/bunExe, concurrent pipe drain, stderr/stdout asserted before exitCode. Subprocess isolation is necessary since both tests mutate process-global state.
  • Checked the fast-path branch (!validateStream && typeof format === "string") — on cache miss it correctly falls through to the general loop that now handles the live lookup.
  • Checked skipColorize interaction — the new path builds and caches the entry even when colorization is skipped, but the trailing if (skipColorize) return text discards the accumulated codes, matching existing behavior for cached entries.
  • The one inline nit (own-property with nullish value passes validateOneOf then hits buildStyleEntry(null)) is real but not a regression — pre-PR crashed on the same input one line later, and Node isn't robust to it either.

Comment thread src/js/node/util.ts
var Error = globalThis.Error captures a reference, not a copy; a user
that deletes or overwrites Error.captureStackTrace still broke
getCallSites. Snapshot the function itself, matching
internal/util/inspect.js and internal/assert/assertion_error.ts.

@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 — small, well-tested follow-up to #34434. The author's rationale on my earlier nit (Node v26.3.0 also crashes on a null inspect.colors entry, and the pre-PR path crashed on the same input) is sound; leaving that as-is is the right call for compat.

What was reviewed:

  • buildStyleEntry extraction and the live inspect.colors[key] lookup on cache miss — invalid names still hit validateOneOf, and the skipColorize path is unaffected (built entry is discarded by the post-loop return text).
  • var Error = globalThis.Error shadows the module-wide binding; audited other in-file uses (new Error in callbackifyOnRejected, Error.prepareStackTrace/stackTraceLimit in getCallSites) — all benefit or are neutral. Matches the existing child_process.ts pattern exactly.
  • Subprocess tests follow harness conventions (bunEnv, concurrent pipe drain, stderr → stdout → exitCode ordering).
Extended reasoning...

Overview

Two narrow follow-up fixes in src/js/node/util.ts to bugs introduced by #34434, plus two subprocess regression tests in test/js/node/util/util.test.js:

  1. styleText: the one-time getStyleCache() snapshot meant a color key added to inspect.colors after the first call would pass validateOneOf (which reads live own-property names) and then crash on style.openSeq. The fix factors the entry-builder into buildStyleEntry(codes) and, on a cache miss, reads the live inspect.colors[key] and caches it. The refactor is a pure extraction — the original body of the getStyleCache() loop is byte-identical to the new helper.
  2. getCallSites: captures Error and Error.captureStackTrace at module load so globalThis.Error = undefined / delete Error.captureStackTrace no longer breaks it. Uses the exact var Error = globalThis.Error pattern already shipped in src/js/node/child_process.ts:1919.

Security risks

None. This is a built-in-module tamper-resistance improvement (capturing globals at load) and a cache-miss fallback for a user-mutable colors table. No parsing of untrusted input, no auth/crypto/fs surface.

Level of scrutiny

Low-to-medium. The runtime change is ~15 net lines, most of which is moving existing code into a named helper. The var Error module-level shadow is the widest-reaching line, so I audited every in-file Error reference: the only other consumer is new Error(...) in callbackifyOnRejected, which now becomes tamper-resistant rather than regressing. getCallSites continues to read/write .prepareStackTrace/.stackTraceLimit on the same underlying constructor object, so JSC's stack-capture machinery still sees the assignments.

Other factors

  • My earlier nit (a user-assigned null color entry falls through validateOneOf and crashes in buildStyleEntry) was declined with a verified rationale: Node v26.3.0 crashes on the same input, and the pre-PR code crashed one line later, so it is neither a regression nor a compat divergence. That resolution is reasonable.
  • CodeRabbit's suggestion to drop the expect(stderr).toBe("") assertion was withdrawn after the author pointed out bunEnv sets BUN_DEBUG_QUIET_LOGS=1 and this pattern is the documented convention.
  • Tests use it.concurrent, drain both pipes with Promise.all, and assert exit code last — matching REVIEW.md's subprocess-test guidance. PR description states fail-before was confirmed and the existing 210 tests still pass.

@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 current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@test/js/node/util/util.test.js`:
- Around line 482-484: Update the test setup around deleting
Error.captureStackTrace to assert immediately after the delete that the property
is absent, before setting globalThis.Error to undefined. Keep the existing
cached Error.captureStackTrace scenario unchanged and ensure the assertion
verifies the deletion precondition.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: ASSERTIVE

Plan: Pro

Run ID: 4d76f0c0-1bf9-4dd4-a2c7-3c6a1a0eaca5

📥 Commits

Reviewing files that changed from the base of the PR and between 4e609e8 and 8222b86.

📒 Files selected for processing (2)
  • src/js/node/util.ts
  • test/js/node/util/util.test.js

Comment thread test/js/node/util/util.test.js
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