console.assert: prepend "Assertion failed" marker when given a message

RO
3mo ago
+124-8

Fixes #31714.
Fixes #19953.

Repro

Two symptoms, one root cause: neither console.assert path follows Node's rule for the Assertion failed marker.

1. Global console.assert / node:console's assert drop the marker entirely.

import { assert } from "node:console";
assert(false, "Whoops %s work", "didn't");
$ node test.js
Assertion failed: Whoops didn't work

$ bun test.js
Whoops didn't work          # missing "Assertion failed: " prefix

2. The Console class string-coerces the first argument into the marker, destroying it.

import { Console } from "node:console";
const c = new Console({ stdout: process.stdout, stderr: process.stderr });
c.assert(false, { code: "E42", user: "u1" }, "ctx");
c.assert(false, [1, 2], "arr");
$ node test.js
Assertion failed { code: 'E42', user: 'u1' } ctx
Assertion failed [ 1, 2 ] arr

$ bun test.js
Assertion failed: [object Object] ctx     # object destroyed
Assertion failed: 1,2 arr                 # array destroyed

This is silent data loss on the assertion-diagnostics path: the object you attached to explain a failed assert is reduced to [object Object].

Cause

Native path. The global console.assert routes through WebKit's ConsoleClient::assertion → the native formatter message_with_type_and_level_ (src/jsc/ConsoleObject.rs). WebKit leaves the "Assertion failed" text entirely to the embedder, and Bun only injected it for the zero-argument case:

if message_type == MessageType::Assert && len == 0 {
    // write "Assertion failed\n", return
}

With arguments, execution fell through to the generic format2(...) call with no prefix, so the marker was dropped.

Console class. src/js/builtins/ConsoleObject.ts built the prefix with an unconditional template literal:

args[0] = `Assertion failed${args.length === 0 ? "" : `: ${args[0]}`}`;

That coerces whatever args[0] is to a string, so an object or array never reaches the formatter as data.

Fix

Both paths now follow Node's Console.prototype.assert (lib/internal/console/constructor.js), which is also what the WHATWG console spec requires:

  • string first arg → becomes Assertion failed: <str> and is still used as the printf-style format string for the rest, so %s etc. keep working;
  • any other first value → a bare Assertion failed is unshifted before it (space separator, no colon), leaving the value intact for the formatter to inspect.

On the native side the freshly-minted prefix string is kept alive with a Protected guard across format2 (a heap Vec<JSValue> is invisible to JSC's conservative stack scan, unlike the caller-owned args).

Behavior (matches Node)

calloutput
(false, "Whoops %s work", "didn't")Assertion failed: Whoops didn't work
(false, "plain message")Assertion failed: plain message
(false, 42)Assertion failed 42
(false, { code: "E42" }, "ctx")Assertion failed { code: 'E42' } ctx
(false, [1, 2], "arr")Assertion failed [ 1, 2 ] arr
(false, "obj:", { a: 1 })Assertion failed: obj: { a: 1 }
(false, "")Assertion failed:
(false)Assertion failed

Verification

New tests in test/js/web/console/console-log.test.ts cover both the global/node:console native path and the custom new Console() JS path, including the object and array first-argument cases:

  • USE_SYSTEM_BUN=1 bun test test/js/web/console/console-log.test.ts → fail (Assertion failed: [object Object] ctx, Assertion failed: 1,2 arr, and no prefix at all on the native path)
  • bun bd test test/js/web/console/console-log.test.ts → pass

Existing test/js/web/console/, test/js/bun/console/, test/js/node/console/, and the ported test/js/node/test/parallel/test-console-*.js suites still pass (no regressions).

Related PRs

  • #20520 by @AdarshRawat1 made the same Console class change (src/js/builtins/ConsoleObject.ts: colon prefix for a string first argument, bare marker unshifted otherwise) a year before this PR. It predates the native port, so it has no counterpart to the src/jsc/ConsoleObject.rs half, and its test case (a plain string message on the global console.assert) is covered by the test here. It is credited with a Co-authored-by trailer on the first commit of this PR and closed in favor of it.
  • #37128 reworks console output as a whole and adds the prefix on the native path, but not on the Console class path (symptom 2 above). If it lands first, the ConsoleObject.rs hunk here drops out on rebase; the ConsoleObject.ts change and the tests still apply.

[review] gate passed · iteration 1 · 3 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/web/console/console-log.test.ts
bun test v1.4.0 (12b1f85b9)

test/js/web/console/console-log.test.ts:
(pass) should log to console correctly [1276.56ms]
(pass) long arrays get cutoff [4.31ms]
(pass) console.group [412.97ms]
(pass) console.log with SharedArrayBuffer [6.62ms]
177 |     stderr: "pipe",
178 |   });
179 | 
180 |   const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]);
181 | 
182 |   expect(stderr.replaceAll("\r\n", "\n")).toBe(
                                                ^
error: expect(received).toBe(expected)

- "Assertion failed: Whoops didn't work
- Assertion failed: plain message
- Assertion failed 42
- Assertion failed [ 1, 2 ] arr
- Assertion failed {
+ "Whoops didn't work
+ plain message
+ 42
+ [ 1, 2 ] arr
+ {
    code: "E42",
    user: "u1",
  } ctx
- Assertion failed: 
+ 
  Assertion failed
- Assertion failed: from node:console works
+ from node:console works
  "

- Expected  - 7
+ Received  + 7

      at <anonymous> (/workspace/bun/test/js/web/con
... (truncated)

release without fix: 2 FAILED
bun test v1.4.0-canary.1 (da3851e57)

test/js/web/console/console-log.test.ts:
(pass) should log to console correctly [19.27ms]
(pass) long arrays get cutoff [0.17ms]
(pass) console.group [12.43ms]
(pass) console.log with SharedArrayBuffer [0.10ms]
177 |     stderr: "pipe",
178 |   });
179 | 
180 |   const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]);
181 | 
182 |   expect(stderr.replaceAll("\r\n", "\n")).toBe(
                                                ^
error: expect(received).toBe(expected)

- "Assertion failed: Whoops didn't work
- Assertion failed: plain message
- Assertion failed 42
- Assertion failed [ 1, 2 ] arr
- Assertion failed {
+ "Whoops didn't work
+ plain message
+ 42
+ [ 1, 2 ] arr
+ {
    code: "E42",
    user: "u1",
  } ctx
- Assertion failed: 
+ 
  Assertion failed
- Assertion failed: from node:console works
+ from node:console works
  "

- Expected  - 7
+ Received  + 7

      at <anonymous> (/workspace/bun/test/js/web/console/console-log.test.ts:182:43)
(fail) console.assert prepends the Assertion failed marker [23.04ms]
222 |     stderr: "pipe",
223 |   });
224 | 
225 |   const [stdou
... (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/web/console/console-log.test.ts
bun test v1.4.0 (12b1f85b9)

test/js/web/console/console-log.test.ts:
(pass) should log to console correctly [1752.47ms]
(pass) long arrays get cutoff [6.35ms]
(pass) console.group [678.70ms]
(pass) console.log with SharedArrayBuffer [7.57ms]
(pass) console.assert prepends the Assertion failed marker [646.67ms]
(pass) custom Console.assert prepends the Assertion failed marker like Node [4085.80ms]

 6 pass
 0 fail
 2 snapshots, 15 expect() calls
Ran 6 tests across 1 file. [13.15s]
__F:0:S:0

release with fix: all passed
$ bun scripts/build.ts --profile=release
[configured] bun-profile → bun (stripped) in 2341ms (unchanged)
ninja: Entering directory `/workspace/bun/build/release'
[1/548] fetch WebKit (prebuilt)
[WebKit] up to date
[2/548] cxx obj/vendor/boringssl/crypto/engine/engine.cc.o
[3/548] cxx obj/vendor/boringssl/crypto/ecdh/ecdh.cc.o
[4/548] cxx obj/vendor/boringssl/crypto/evp/evp.cc.o
[5/548] cxx obj/vendor/boringssl/crypto/evp/evp_asn1.cc.o
[6/548] cxx obj/vendor/boringssl/crypto/evp/evp_ctx.cc.o
[7/548] cxx obj/vendor/boringssl/crypto/err/err.cc.o
[8/548] cxx obj/vendor/boringssl/crypto/ecdsa/ecdsa_asn1.cc.o
[9/548] cxx obj/vendor/boringssl/crypto/ecdsa/ecdsa_p1363.cc.o
[10/548] gen generated_host_exports.rs
generated_host_exports.rs: 93 exports (host=3, lazy=10, generic=80, rust=0); 239 extern-C blocks audited
[11/548] cxx obj/vendor/boringssl/crypto/ec/ec_derive.cc.o
[12/548] cxx obj/vendor/boringssl/crypto/ec/ec_asn1.cc.o
[13/548] cxx obj/vendor/boringssl/crypto/ec/hash_to_curve.cc.o
[14/548] cxx obj/vendor/boringssl/crypto/evp/evp_kem.cc.o
[15/548] cxx obj/vendor/boringssl/crypto/evp/p_dh.cc.o
[16/548] cxx obj/vendor/boringssl/crypto/evp/p_dsa.cc.o
[17/548] cxx obj
... (truncated)
diff hotspot
src/js/builtins/ConsoleObject.ts        |  7 ++-
 src/jsc/ConsoleObject.rs                | 38 +++++++++++---
 test/js/web/console/console-log.test.ts | 87 +++++++++++++++++++++++++++++++++
 3 files changed, 124 insertions(+), 8 deletions(-)

gate history · 2 passed · 0 rejected · iteration 1

evidence per changed file
file                                     reads  edits  tests
src/js/builtins/ConsoleObject.ts             1      1      0
src/jsc/ConsoleObject.rs                     1      2      0
test/js/web/console/console-log.test.ts      1      2      0

root cause · written by the author bot

Bun's console.assert passed the user's arguments straight to the formatter when the condition was falsy, so failed assertions never carried the "Assertion failed" marker that Node and the WHATWG console spec require. The fix updates both the native formatter in ConsoleObject.rs and the JS builtin Console class so that a failing assertion with a string first argument becomes "Assertion failed: " while still serving as the printf format string, and any non-string first argument gets a separate "Assertion failed" value unshifted ahead of it. On the native side the new prefix string is…