node:util: fix isError crash on revoked proxies and getPrototypeOf traps
Problem
-
util.isError()crashes the process instead of returning or throwing when given a revoked Proxy or a Proxy with agetPrototypeOftrap. Release builds die withpanic(main thread): Segmentation fault at address 0x0; debug builds abort inVM::checkVMEntryPermission().const r = Proxy.revocable({}, {}); r.revoke(); require("node:util").isError(r.proxy); // segfault require("node:util").isError(new Proxy({}, { getPrototypeOf() { throw 1; } })); // segfault require("node:util").isError(new Proxy({}, { getPrototypeOf: () => Error.prototype })); // segfault too -
The deprecated API is not the only way in:
internal/streams/iter/utils.ts(wrapError) runs every value a source throws throughutil.isError, so a program that throws such a Proxy intonode:stream/itersegfaults too (probe in the details block below). -
Cause 1,
jsFunctionIsErrorinsrc/jsc/modules/NodeUtilTypesModule.cpp: the result ofobject->getPrototype(globalObject)is used with no exception check. When the call throws (revoked proxy, throwing trap) it returns the emptyJSValue, which passesisCell(), soproto.inherits<ErrorInstance>()reads the structure of a null cell. -
Cause 2, same function: the
VMInquiryPropertySlotused a few lines earlier for theSymbol.toStringTaglookup is still alive whengetPrototype()runs, so itsDisallowVMEntryis still in force when the Proxy tries to call the trap. Debug builds crash there; release builds skip the trap and getundefinedback, which the Proxy turns into a TypeError, which then feeds cause 1. This is why even a well behaved trap (returningError.prototypeornull) crashed.
Fix
slot.disallowVMEntry.reset()once thetoStringTaglookup has returned. This is the idiom JSC itself uses when a VMInquiry slot is followed by code that may run JS (JSScope.cpp,JSGlobalObject.cpp,JSInjectedScriptHost.cpp). The only later use of the slot isgetValue()on a slot thatisValue(), which reads a stored value and does not enter the VM.RETURN_IF_EXCEPTIONaftergetPrototype(), so a revoked proxy throws its TypeError and a throwing trap rethrows the trap's own value.- Why this is the right behavior: Node's legacy
util.isError(e)ends ine instanceof Error, which performs the same[[GetPrototypeOf]]on the proxy. It throws on a revoked proxy, propagates the trap's exception unchanged, and honors a trap's return value (nullis not an Error,Error.prototypeis). The new test asserts exactly those outcomes. - Verified with
test/js/node/util/util.test.js,util > isError > handles revoked proxies and getPrototypeOf traps. The crashing inputs run in a child process, one line of output per case: revoked proxy throws a TypeError, a throwing trap propagates its Error, a trap throwing a plain object rethrows that same object (caught === thrown),nulltrap givesfalse,Error.prototypetrap givestrue, a Proxy wrapping an Error givestrue.- Debug build with
NodeUtilTypesModule.cppreverted to main: the test fails (the child aborts); the same-object case run on its own aborts with exit 134. - Debug build with the fix: the file passes (209 tests).
USE_SYSTEM_BUN=1(release 1.4.0 canary): the test fails, the child segfaults.
- Debug build with
This PR replaces #37202, which fixed the same two problems in the same function; its extra test case (the trap's thrown value comes back as the same object) has been folded into the test here.
Not changed here: jsFunctionIsError still only looks one prototype level deep and at Symbol.toStringTag, which differs from Node's toString(e) === "[object Error]" || e instanceof Error for some non-crashing inputs. That is a behavior question separate from this crash and is left as is.
Background
PropertySlotwithInternalMethodType::VMInquiryasks JSC for a lookup with no observable side effects (no getters, no Proxy traps). To enforce that, the slot holds aDisallowVMEntrytoken for its whole lifetime; while any such token exists, JSC refuses to enter the VM to run JavaScript (debug: crash, release: the call is skipped and yieldsundefined).- A throwing JSC host call does not unwind. It records the exception on the VM and returns a placeholder (the empty
JSValuehere); the caller has to check the throw scope (RETURN_IF_EXCEPTION) before touching the result. - The empty
JSValueis encoded as 0, which theisCell()fast check accepts, so using it as a cell is a null dereference.
Probe: reaching the crash through node:stream/iter
// bun --experimental-stream-iter repro.js
const { share } = require("node:stream/iter");
const poison = new Proxy({}, { getPrototypeOf() { throw new Error("trap"); } });
async function* src() { throw poison; }
(async () => {
try {
for await (const x of share(src()).pull()) {}
} catch (e) {
console.log("caught:", String(e));
}
})();
bun 1.4.0 canary (8326d1bd3): panic(main thread): Segmentation fault at address 0x0, exit 139.
With this PR: caught: Error: trap, exit 0.
[review] gate passed · iteration 5 · 2 files touched
fails on main (without fix)
ASAN without fix: 1 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 (8326d1bd3)
test/js/node/util/util.test.js:
(pass) util > toUSVString [5.02ms]
(pass) util > inherits [5.33ms]
(pass) util > isArray > all cases [7.18ms]
(pass) util > isRegExp > all cases [4.38ms]
(pass) util > isDate > all cases [165.31ms]
(pass) util > isError > all cases [15.19ms]
185 | env: bunEnv,
186 | stdout: "pipe",
187 | stderr: "pipe",
188 | });
189 | const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]);
190 | expect(stderr).toBe("");
^
error: expect(received).toBe(expected)
- ""
+ "/root/.bun/build-cache/webkit-c6cfe90c6064bd80-debug-asan/include/JavaScriptCore/JSCJSValueStructure.h:45:34: runtime error: member call on null pointer of type 'JSC::JSCell'
+ SUMMARY: UndefinedBehaviorSanitizer: undefined-behavior /root/.bun/build-cache/webkit-c6cfe90c6064bd80-debug-asan/include/JavaScriptCore/JSCJSValueStructure.h:45:34
+ "
- Expected - 1
+ Received + 3
... (truncated)
release without fix: all passed
bun test v1.4.0-canary.1 (28977160c)
test/js/node/util/util.test.js:
(pass) util > toUSVString [0.11ms]
(pass) util > inherits [0.10ms]
(pass) util > isArray > all cases [0.10ms]
(pass) util > isRegExp > all cases [0.05ms]
(pass) util > isDate > all cases [6.04ms]
(pass) util > isError > all cases [0.32ms]
(pass) util > isError > handles revoked proxies and getPrototypeOf traps [17.19ms]
(pass) util > isObject > all cases [0.11ms]
(pass) util > isPrimitive > all cases [0.11ms]
(pass) util > isBuffer > all cases [0.05ms]
(pass) util > _extend > all cases [0.13ms]
(pass) util > isBoolean > all cases [0.04ms]
(pass) util > isNull > all cases [0.04ms]
(pass) util > isUndefined > all cases [0.03ms]
(pass) util > isNullOrUndefined > all cases [0.03ms]
(pass) util > isNumber > all cases [0.03ms]
(pass) util > isString > all cases [0.02ms]
(pass) util > isSymbol > all cases [0.03ms]
(pass) util > isFunction > all cases [0.04ms]
(pass) util > types.isNativeError > all cases [0.07ms]
(pass) util > TextEncoder > is same as global TextEncoder [0.02ms]
(pass) util > TextDecoder > is same as global TextDecoder [0.02ms]
(pass) util > format [0.23ms]
(pass) util > formatWithOption
... (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 (8326d1bd3)
test/js/node/util/util.test.js:
(pass) util > toUSVString [4.96ms]
(pass) util > inherits [5.58ms]
(pass) util > isArray > all cases [7.00ms]
(pass) util > isRegExp > all cases [3.70ms]
(pass) util > isDate > all cases [175.40ms]
(pass) util > isError > all cases [17.81ms]
(pass) util > isError > handles revoked proxies and getPrototypeOf traps [852.71ms]
(pass) util > isObject > all cases [4.70ms]
(pass) util > isPrimitive > all cases [9.14ms]
(pass) util > isBuffer > all cases [4.43ms]
(pass) util > _extend > all cases [7.81ms]
(pass) util > isBoolean > all cases [2.75ms]
(pass) util > isNull > all cases [2.91ms]
(pass) util > isUndefined > all cases [2.82ms]
(pass) util > isNullOrUndefined > all cases [2.81ms]
(pass) util > isNumber > all cases [2.70ms]
(pass) util > isString > all cases [2.64ms]
(pass) util > isSymbol > all cases [2.67ms]
(pass) util > isFunction > all cases [3.09ms]
(pass) util > types.isNativeError > all cases [4.29ms]
(pass) util > TextEncoder > is same
... (truncated)
release with fix: all passed
$ bun scripts/build.ts --profile=release
[configured] bun-profile → bun (stripped)
target linux-x64-gnu
build type Release
build dir ./build/release
revision 871f486d72
features baseline
22 deps, 120 codegen, 1175 objects in 637ms
ninja: Entering directory `/workspace/bun/build/release'
[1/127] gen generated_host_exports.rs
generated_host_exports.rs: 92 exports (host=3, lazy=10, generic=79, rust=0); 241 extern-C blocks audited
[2/127] gen cpp.rs (cppbind)
[3/127] gen BunProcess.lut.h
Generating /workspace/bun/build/release/codegen/BunProcess.lut.h from /workspace/bun/src/jsc/bindings/BunProcess.cpp
[4/127] gen JS modules (bundle-modules)
Preprocess modules (8084ms)
Bundle modules (45ms)
Postprocesss modules (32ms)
Bundle Functions (651ms)
Generate Code (19ms)
[8.85s] Bundled "src/js" for production
2631 kb
197 internal modules
13 native modules
91 internal functions across 17 files
[4/127] 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 Compiling^[[0m std v0.0.0 (/root/.rustup/toolchains/nigh
... (truncated)
diff hotspot
src/jsc/modules/NodeUtilTypesModule.cpp | 3 ++
test/js/node/util/util.test.js | 51 ++++++++++++++++++++++++++++++++-
2 files changed, 53 insertions(+), 1 deletion(-)
gate history · 4 passed · 1 rejected · iteration 5
evidence per changed file
file reads edits tests
src/jsc/modules/NodeUtilTypesModule.cpp 5 6 0
test/js/node/util/util.test.js 5 7 0