Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
33 commits
Select commit Hold shift + click to select a range
90e0621
console, process.stdout/stderr: one stdio sink per fd, console writes…
dylan-conway Aug 7, 2026
cde99d8
Merge branch 'main' into claude/node-console-stdout-compat-7d6001
dylan-conway Aug 7, 2026
584ddb3
clippy: ref_as_ptr, unnecessary_map_or, undocumented unsafe blocks
dylan-conway Aug 7, 2026
40db416
review: stdio sink follow-ups
dylan-conway Aug 7, 2026
b704f36
ci: exception-scope discipline for console stream lookups, test fixes
dylan-conway Aug 7, 2026
342d214
aarch64 baseline: use the allowlisted acq_rel outline atomic for STDI…
dylan-conway Aug 7, 2026
e6f489e
FileSink::write_with: report this call's byte count, not the flushed …
dylan-conway Aug 7, 2026
8566d73
stdio sink: only trust RWF_NOWAIT on pipefs pipes; tolerate an fd clo…
dylan-conway Aug 7, 2026
70e70ac
writeToObservedStream: same removeListener guard as Console.write
dylan-conway Aug 7, 2026
7bd8448
review: latch autoflush errors on stdio sinks; downgrade sinks after …
dylan-conway Aug 7, 2026
b7a141f
console: drop SCRATCH_KEEP; the scratch buffer shrinks back to SPILL_…
dylan-conway Aug 7, 2026
cec5076
Merge remote-tracking branch 'origin/main' into claude/node-console-s…
dylan-conway Aug 7, 2026
0e78647
process.stdout/stderr.write keep accepting a bare ArrayBuffer / Share…
dylan-conway Aug 7, 2026
7eaf6ab
Address risk review: writer coalescing, async Bun.write, killable exi…
dylan-conway Aug 8, 2026
f35776a
FileSink: make the RWF_NOWAIT flag field Linux-only instead of allow(…
dylan-conway Aug 8, 2026
4b248ac
Merge remote-tracking branch 'origin/main' into claude/node-console-s…
dylan-conway Aug 8, 2026
1e52b84
Merge remote-tracking branch 'origin/main' into claude/node-console-s…
dylan-conway Aug 8, 2026
e63575b
exit drain: only un-hook signals that were being forwarded to JS list…
dylan-conway Aug 8, 2026
c167a8b
docs: put three misplaced doc comments back on the items they describe
dylan-conway Aug 8, 2026
b5a2451
review: idempotent RWF_NOWAIT branch in stdio_go_nonblocking; Stream.…
dylan-conway Aug 8, 2026
7d7d6dc
stdio_go_nonblocking: never re-set O_NONBLOCK once a spawn has handed…
dylan-conway Aug 8, 2026
67b116c
console emit: decide colours before taking the scratch buffer
dylan-conway Aug 8, 2026
46f1c79
Merge remote-tracking branch 'origin/main' into claude/node-console-s…
dylan-conway Aug 8, 2026
ab797ac
console: stop (and report once) after a failed spill; docs: Bun.write…
dylan-conway Aug 8, 2026
f07b71e
napi test fixture: allocate the finalizer objects in a callee frame a…
dylan-conway Aug 8, 2026
a41ced3
Merge remote-tracking branch 'origin/main' into claude/node-console-s…
dylan-conway Oct 13, 2026
9033ecd
Cut the PR down to one change: console.* flushes what process.stdout/…
dylan-conway Oct 13, 2026
3464a31
FileSink: do not settle a pending write whose slot backed up again, a…
dylan-conway Oct 13, 2026
83e1363
console: flush process.stdout/stderr's sink the way the event loop do…
dylan-conway Oct 13, 2026
8658131
console: the flush only moves bytes and leaves a write error for the …
dylan-conway Oct 14, 2026
75e6511
test: do not call console.assert(false), which fails exception valida…
dylan-conway Oct 14, 2026
1954570
Merge remote-tracking branch 'origin/main' into claude/node-console-s…
dylan-conway Oct 14, 2026
7f91520
console: output for a backed-up process.stdout/stderr goes into that …
dylan-conway Oct 14, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
39 changes: 25 additions & 14 deletions src/jsc/ConsoleObject.rs
Original file line number Diff line number Diff line change
Expand Up @@ -270,6 +270,13 @@ unsafe fn vm_console_mut<'a>(global: &JSGlobalObject) -> &'a mut ConsoleObject {
unsafe { &mut *vm_console(global) }
}

unsafe extern "Rust" {
/// Defined in `bun_runtime::webcore::file_sink`: the sink behind `process.stdout` (1) / `process.stderr` (2) holds bytes a full pipe or socket refused.
safe fn __bun_stdio_sink_is_backed_up(global: &JSGlobalObject, fd: i32) -> bool;
/// Appends `message` behind those bytes; `false`, with nothing appended, if there are none any more.
safe fn __bun_stdio_sink_append(global: &JSGlobalObject, fd: i32, message: &[u8]) -> bool;
}

static STDERR_MUTEX: Mutex = Mutex::new();
static STDOUT_MUTEX: Mutex = Mutex::new();

Expand Down Expand Up @@ -445,19 +452,27 @@ fn message_with_type_and_level_(
let default_indent = unsafe { vm_console_mut(global) }.default_indent;

// SAFETY: see [`vm_console`] — `console` points at the live boxed
// `ConsoleObject` for this VM; JS-thread-only. Kept as a raw deref (not
// `vm_console_mut`) so the resulting `writer` borrow does not pin a
// long-lived `&mut ConsoleObject` across the re-derive in the empty-`Log`
// arm below.
let raw_writer: &mut bun_core::io::Writer = unsafe {
// `ConsoleObject` for this VM; JS-thread-only.
let (raw_writer, fd): (&mut bun_core::io::Writer, i32) = unsafe {
if matches!(level, MessageLevel::Warning | MessageLevel::Error) {
(*console).error_writer()
((*console).error_writer(), 2)
} else {
(*console).writer()
((*console).writer(), 1)
}
};
// As in Node, a message for a stream that is backed up is formatted first and then goes behind what the stream buffered.
let mut behind_sink = None;
// `bun_core::io::Writer: bun_io::Write` — `&mut Writer` unsize-coerces directly.
let writer: &mut dyn bun_io::Write = raw_writer;
let writer: &mut dyn bun_io::Write = if __bun_stdio_sink_is_backed_up(global, fd) {
&mut **behind_sink.insert(scopeguard::guard(Vec::new(), move |message: Vec<u8>| {
if !__bun_stdio_sink_append(global, fd, &message) {
let _ = raw_writer.write_all(&message);
let _ = raw_writer.flush();
}
}))
Comment on lines +466 to +472

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.

🔴 Programs with Web Workers can now get a worker's console line spliced into the middle of a main-thread console message when stdout is backed up; on the base every console message reached the pipe whole. The message is appended to the sink at src/jsc/ConsoleObject.rs:468 and later written by the poll's drain in as many write(2) calls as the pipe allows, outside the console lock taken at src/jsc/ConsoleObject.rs:419, so a worker's direct console write can land between two parts. Fix: keep a console message atomic across threads once it is queued, e.g. drain the sink's tail under the per-fd console lock or write each queued message in one write(2) that is not split across rounds.

Why this was flagged

Trigger: fd 1 is a pipe or socket to a slow reader; the main thread runs process.stdout.write(Buffer.alloc(1 << 20)) (returns false), then several console.log lines, while a Web Worker (its own ConsoleObject, same fd 1, same STDOUT_MUTEX) also logs. The bytes are written later by on_poll at src/io/PipeWriter.rs:180-188, where drain_buffered_data (src/io/PipeWriter.rs:226-247) writes as much as the kernel takes and returns Pending at an arbitrary byte offset, which can fall inside a queued console message; the rest goes out on a later round after register_poll. No lock is held during those rounds, so the worker's console.log, which writes under the lock straight to fd 1 through its raw writer, lands between the two halves. On the base branch the main thread wrote the whole message through raw_writer and its flush while holding ConsoleStreamLock (src/jsc/ConsoleObject.rs:419), polling on EAGAIN under the lock, so other threads' console output could never split a message.

Verification: On this branch, when __bun_stdio_sink_is_backed_up is true (src/jsc/ConsoleObject.rs:466), the message is formatted into a Vec and, at the scopeguard drop (:467-472), handed to __bun_stdio_sink_append (src/runtime/webcore/FileSink.rs:236-239), which only does w.outgoing.write(message). The lock is released when _stream_lock drops at scope exit; nothing has been written to the fd yet.

Comment on lines +466 to +472

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.

🟡 (optional) Users piping to a slow consumer now lose a console line entirely when process.exit() (or an 'exit' listener's log) follows a backed-up process.stdout.write; the base always delivered it. When __bun_stdio_sink_is_backed_up is true at src/jsc/ConsoleObject.rs:466, the formatted message is only appended to the sink's outgoing buffer at :468, and nothing drains that buffer at exit. This follows from the stated purpose (Node parity), and Node drops it too, so the call is the author's. Fix: if console output must survive process.exit() as on the base, drain the stdio sinks' outgoing synchronously before the process exits (also covers 'exit' listeners); otherwise document the loss.

Why this was flagged

Trigger: fd 1 is a pipe or socket to a slow reader (bun app.js | gzip); user code runs process.stdout.write(Buffer.alloc(1 << 20)) (returns false, remainder queued in the FileSink's outgoing) and then console.log("done"); process.exit(0) — or logs from a process.on('exit') listener reached by that process.exit. In message_with_type_and_level_, __bun_stdio_sink_is_backed_up(global, fd) at src/jsc/ConsoleObject.rs:466 is true, so the message is handed to __bun_stdio_sink_append at :468, which only does w.outgoing.write(message) (src/runtime/webcore/FileSink.rs:236-238). No code drains that buffer at exit: there is no drain/flush of m_stdioSinks in VirtualMachine.rs or FileSink.rs. On the base branch the console wrote "done\n" synchronously (blocking on EAGAIN) to fd 1 before process.exit ran, so the line reached the reader. After merging the line is silently lost together with the rest of the backlog; Node behaves the same, so this follows from the PR's purpose, but it is a new data-loss case relative to the base.

Verification: src/jsc/ConsoleObject.rs:466-472 — when __bun_stdio_sink_is_backed_up(global, fd) is true __bun_stdio_sink_append does w.outgoing.write(message). Nothing drains the FileSink's outgoing to completion, so the queued console line dies with the backlog's tail. On the base the console writer wrote the line directly and synchronously, so the line always reached the reader.

} else {
raw_writer
};

// LAYERING: `Jest::runner()` lives in `bun_runtime::test_runner` (forward
// dep on the high tier). Dispatch through `RuntimeHooks` instead — the
Expand Down Expand Up @@ -541,12 +556,8 @@ fn message_with_type_and_level_(
print_options,
)?;
} else if message_type == MessageType::Log {
// SAFETY: see [`vm_console`]. `writer` (above) is dead in this arm —
// the only later uses are in the mutually-exclusive `Trace` block, and
// `message_type == Log` here.
let w = unsafe { (*console).writer() };
let _ = w.write_all(b"\n");
let _ = w.flush();
let _ = writer.write_all(b"\n");
let _ = writer.flush();
} else if message_type != MessageType::Trace {
let _ = writer.write_all(b"undefined\n");
}
Expand Down
15 changes: 13 additions & 2 deletions src/jsc/bindings/BunProcess.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -3030,21 +3030,30 @@ static JSValue constructStdioWriteStream(JSC::JSGlobalObject* globalObject, JSC:
// Note: files are always sync anyway.
// forceSync = fdType == BunProcessStdinFdType::file || bun_stdio_tty[fd];

// TODO: once console.* is wired up to write/read through the same buffering mechanism as FileSink for process.stdout, process.stderr, we can make this non-blocking for sockets on POSIX.
// Until then, we have to force it to be sync EVEN for sockets or else console.log() may flush at a different time than process.stdout.write.
// Turns off FileSink's coalescing of small writes. What a full pipe or socket refuses is still buffered, and console.* output goes behind it (m_stdioSinks).
forceSync = true;
#endif
if (forceSync) {
JSValue sink = resultObject->getIndex(globalObject, 1);
RETURN_IF_EXCEPTION(scope, jsUndefined());
Bun__ForceFileSinkToBeSynchronousForProcessObjectStdio(globalObject, JSValue::encode(sink));
if (auto* sinkObject = sink.getObject())
uncheckedDowncast<Process>(processObject)->m_stdioSinks[fd - 1].set(vm, processObject, sinkObject);
}

JSValue stream = resultObject->getIndex(globalObject, 0);
RETURN_IF_EXCEPTION(scope, jsUndefined());
return stream;
}

extern "C" JSC::EncodedJSValue Bun__Process__getStdioSink(JSC::JSGlobalObject* lexicalGlobalObject, int fd)
{
ASSERT(fd == 1 || fd == 2);
auto* globalObject = defaultGlobalObject(lexicalGlobalObject);
JSObject* sink = globalObject->hasProcessObject() ? globalObject->processObject()->m_stdioSinks[fd - 1].get() : nullptr;
return JSValue::encode(sink ? JSValue(sink) : jsUndefined());
}

static JSValue constructStdout(VM& vm, JSObject* processObject)
{
return constructStdioWriteStream(processObject->globalObject(), processObject, 1);
Expand Down Expand Up @@ -3782,6 +3791,8 @@ void Process::visitChildrenImpl(JSCell* cell, Visitor& visitor)
visitor.append(thisObject->m_argv);
visitor.append(thisObject->m_execArgv);
visitor.append(thisObject->m_onWarning);
for (auto& sink : thisObject->m_stdioSinks)
visitor.append(sink);

thisObject->m_cpuUsageStructure.visit(visitor);
thisObject->m_resourceUsageStructure.visit(visitor);
Expand Down
2 changes: 2 additions & 0 deletions src/jsc/bindings/BunProcess.h
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,8 @@ class Process : public WebCore::JSEventEmitter {

DECLARE_EXPORT_INFO;
bool m_reportOnUncaughtException = false;
// The FileSinks behind process.stdout ([0]) and process.stderr ([1]): console.* output goes behind what they have buffered.
WriteBarrier<JSObject> m_stdioSinks[2];

static void destroy(JSC::JSCell* cell)
{
Expand Down
28 changes: 28 additions & 0 deletions src/runtime/webcore/FileSink.rs
Original file line number Diff line number Diff line change
Expand Up @@ -210,6 +210,34 @@ impl FileSink {
}
}

unsafe extern "C" {
/// The `FileSink` wrapper behind `process.stdout` (1) / `process.stderr` (2), or `undefined` before that stream exists.
safe fn Bun__Process__getStdioSink(global: &JSGlobalObject, fd: i32) -> JSValue;
}

/// The writer of that sink, while it holds bytes a full pipe or socket refused.
fn backed_up_stdio_writer(global: &JSGlobalObject, fd: i32) -> Option<&JsCell<IOWriter>> {
let this_ptr = JSSink::from_js(Bun__Process__getStdioSink(global, fd))?;
// SAFETY: `from_js` returned the wrapper's live `m_ctx`; `global`'s `Process` keeps the wrapper alive.
let writer = unsafe { &(*this_ptr).sink.writer };
// A writer whose poll failed to register is closed with bytes left, and nothing writes them.
#[cfg(unix)]
writer.get().get_poll()?;
writer.get().has_pending_data().then_some(writer)
}

#[unsafe(no_mangle)]
fn __bun_stdio_sink_is_backed_up(global: &JSGlobalObject, fd: i32) -> bool {
backed_up_stdio_writer(global, fd).is_some()
}

/// The poll that is armed for the bytes already there writes `message` after them.
#[unsafe(no_mangle)]
fn __bun_stdio_sink_append(global: &JSGlobalObject, fd: i32, message: &[u8]) -> bool {
backed_up_stdio_writer(global, fd)
.is_some_and(|writer| writer.with_mut(|w| w.outgoing.write(message)).is_ok())
Comment on lines +236 to +238

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.

🟡 (optional) Programs that log in a synchronous loop after one backed-up process.stdout.write now hold their whole output in memory and print nothing until the loop ends. Once the sink's outgoing is non-empty, every console.* call reaches __bun_stdio_sink_append at src/runtime/webcore/FileSink.rs:238, which only extends outgoing and never tries write(2) or waits for the reader, so the backlog grows by one message per call. Fix: bound the buffered bytes on this path, e.g. when outgoing is above a cap, flush() the writer and poll(POLLOUT) until it drops below it before appending, so order is kept but memory and latency stay bounded as on the base.

Why this was flagged

Trigger: fd 1 is a pipe or socket whose reader has not yet drained it, user code runs process.stdout.write(chunk) that returns false, then a synchronous loop of console.log. message_with_type_and_level_ at src/jsc/ConsoleObject.rs:466 sees __bun_stdio_sink_is_backed_up and formats into a Vec; its drop guard at ConsoleObject.rs:468 calls __bun_stdio_sink_append, which at FileSink.rs:238 does w.outgoing.write(message) and returns. No write(2), flush or poll happens on this path, and nothing else drains outgoing while JS runs synchronously, so every message of the loop accumulates in outgoing until the loop returns to the event loop. On the base branch console.log wrote straight to fd 1 and fd_write_all_quiet at src/sys/lib.rs:9235 waited on poll(POLLOUT) on EAGAIN, so memory stayed flat and output streamed at the reader's pace; a multi-gigabyte dump that streamed on the base now needs that much RAM and shows nothing until the loop ends. Node buffers similarly, but the base did not.

Verification: src/jsc/ConsoleObject.rs:466 picks the Vec path whenever __bun_stdio_sink_is_backed_up is true, and the drop guard at :468 calls __bun_stdio_sink_append; src/runtime/webcore/FileSink.rs:236-238 only does writer.with_mut(|w| w.outgoing.write(message)), no try_write, no flush, no poll wait, so bytes leave only after the synchronous JS returns. The base's console path wrote (blocking via poll on EAGAIN) per call.

}

#[unsafe(no_mangle)]
pub(crate) extern "C" fn Bun__ForceFileSinkToBeSynchronousForProcessObjectStdio(
_global: *mut JSGlobalObject,
Expand Down
137 changes: 127 additions & 10 deletions test/js/node/process/process-stdio.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -163,9 +163,9 @@ describe.concurrent("process-stdio", () => {

// Like Node, process.stdout/stderr put O_NONBLOCK on fd 1/2 when they are pipes or sockets. The flag lives on the shared open
// file description, so spawn clears it again for a child's fds 0-2 (libuv does the same), and the native console writer must
// wait on EAGAIN instead of dropping the rest of the line.
// wait on EAGAIN instead of dropping the rest of the line, and its output goes behind what the stream has buffered.
describe.concurrent.skipIf(isWindows)(
"O_NONBLOCK on process.stdout/stderr does not leak to children or truncate console output",
"process.stdout/stderr on a pipe or socket: O_NONBLOCK does not leak to children, console output is whole and in call order",
() => {
const probe = path.join(import.meta.dir, "fd-nonblock-fixture.js");

Expand Down Expand Up @@ -227,6 +227,17 @@ describe.concurrent.skipIf(isWindows)(
expect(exitCode).toBe(0);
}

async function signalReaderOnceFull(events: ReturnType<typeof jsonLines>) {
let readerPid: number | undefined, ret: boolean | undefined;
while (readerPid === undefined || ret === undefined) {
const v = await events.next();
if ("reader" in v) readerPid = v.reader;
if ("ret" in v) ret = v.ret;
}
expect(ret).toBe(false);
process.kill(readerPid, "SIGUSR1");
}

// Real pipe(2) via sh, reader gated on SIGUSR1.
async function expectDrainOverPipe(
prelude: string,
Expand All @@ -241,14 +252,7 @@ describe.concurrent.skipIf(isWindows)(
stderr: "pipe",
});
const events = jsonLines(proc.stderr);
let readerPid: number | undefined, ret: boolean | undefined;
while (readerPid === undefined || ret === undefined) {
const v = await events.next();
if ("reader" in v) readerPid = v.reader;
if ("ret" in v) ret = v.ret;
}
expect(ret).toBe(false);
process.kill(readerPid!, "SIGUSR1");
await signalReaderOnceFull(events);
expect(await events.next()).toEqual(expectedDrain);
const [stdout, exitCode] = await Promise.all([proc.stdout.bytes(), proc.exited]);
expect(stdout.byteLength).toBe(expectedBytes);
Expand Down Expand Up @@ -371,5 +375,118 @@ describe.concurrent.skipIf(isWindows)(
await expectDrainOverPipe(afterInheritSpawn, "{}", { drained: true }, 1 + (1 << 20));
},
);

// Each console call and what it prints. Reports go to the other fd; nothing reads the fd under test until the first write() returned false.
const stdoutCalls = [
[`log("<log>")`, "<log>"],
[`info("<info>")`, "<info>"],
[`debug("<debug>")`, "<debug>"],
[`log()`, ""],
] as const;
const stderrCalls = [
[`warn("<warn>")`, "<warn>"],
[`error("<error>")`, "<error>"],
[`error()`, ""],
] as const;
type Calls = typeof stdoutCalls | typeof stderrCalls;
const callOrderScript = (stream: "stdout" | "stderr", calls: Calls) => `
const report = v => require("fs").writeSync(${stream === "stdout" ? 2 : 1}, JSON.stringify(v) + "\\n");
[${calls.map(([call]) => `() => console.${call}`).join(", ")}].forEach((call, i) => {
const ret = process.${stream}.write(Buffer.alloc(1 << 20, String(i)));
if (i === 0) report({ ret });
call();
});`;
const inCallOrder = (calls: Calls) => calls.map(([, printed], i) => `[${i}*${1 << 20}]${printed}\n`).join("");
const squash = (bytes: Uint8Array) =>
Buffer.from(bytes)
.toString()
.replace(/(\d)\1+/g, m => `[${m[0]}*${m.length}]`);

// A pipeline's status is its last command's, so the writer's own goes out with the reports.
const stdoutPipeline = `( "$0" -e "$1"; echo "{\\"writer\\":$?}" >&2 ) | "$0" -e "$2"`;
const stderrPipeline = `{ ( "$0" -e "$1"; echo "{\\"writer\\":$?}" ) 2>&1 >&3 | "$0" -e "$2"; } 3>&2`;
async function expectWriterExitedCleanly(events: ReturnType<typeof jsonLines>) {
expect(await events.next()).toEqual({ writer: 0 });
expect(await events.rest()).toBe("");
}

test.each([
["stdout", stdoutCalls, stdoutPipeline],
["stderr", stderrCalls, stderrPipeline],
] as const)(
"console output on %s comes after what the stream buffered on a full pipe",
async (stream, calls, pipeline) => {
await using proc = spawn({
cmd: ["sh", "-c", pipeline, bunExe(), callOrderScript(stream, calls), gatedReader],
env: bunEnv,
stdout: "pipe",
stderr: "pipe",
});
const events = jsonLines(proc.stderr);
await signalReaderOnceFull(events);
const [stdout, exitCode] = await Promise.all([proc.stdout.bytes(), proc.exited]);
expect(squash(stdout)).toBe(inCallOrder(calls));
await expectWriterExitedCleanly(events);
expect(exitCode).toBe(0);
},
);

// process.exit() drops what is still buffered, so everything written before the callback has to be out by then.
test("the callback of a write() after console output runs once all of it is out", async () => {
const script = `
require("fs").writeSync(2, JSON.stringify({ ret: process.stdout.write(Buffer.alloc(1 << 20, "0")) }) + "\\n");
console.log("<log>");
process.stdout.write(Buffer.alloc(1 << 20, "1"), () => process.exit(0));`;
await using proc = spawn({
cmd: ["sh", "-c", stdoutPipeline, bunExe(), script, gatedReader],
env: bunEnv,
stdout: "pipe",
stderr: "pipe",
});
const events = jsonLines(proc.stderr);
await signalReaderOnceFull(events);
const [stdout, exitCode] = await Promise.all([proc.stdout.bytes(), proc.exited]);
expect(squash(stdout)).toBe(`[0*${1 << 20}]<log>\n[1*${1 << 20}]`);
await expectWriterExitedCleanly(events);
expect(exitCode).toBe(0);
});

// The result on main too. write() returns true once nothing is buffered, so by the end of formatting there is nothing to go behind.
test("a message whose formatting outlasts the backlog is still printed", async () => {
const script = `
require("fs").writeSync(2, JSON.stringify({ ret: process.stdout.write(Buffer.alloc(1 << 20, "0")) }) + "\\n");
console.log({
[Symbol.for("nodejs.util.inspect.custom")]() {
while (!process.stdout.write("1"));
return "<log>";
},
});`;
await using proc = spawn({
cmd: ["sh", "-c", stdoutPipeline, bunExe(), script, gatedReader],
env: bunEnv,
stdout: "pipe",
stderr: "pipe",
});
const events = jsonLines(proc.stderr);
await signalReaderOnceFull(events);
const [stdout, exitCode] = await Promise.all([proc.stdout.bytes(), proc.exited]);
expect(squash(stdout)).toMatch(new RegExp(String.raw`^\[0\*${1 << 20}\](1|\[1\*\d+\])<log>\n$`));
await expectWriterExitedCleanly(events);
expect(exitCode).toBe(0);
});

test("console output on stdout comes after what the stream buffered on a full socket", async () => {
await using proc = spawn({
cmd: [bunExe(), "-e", callOrderScript("stdout", stdoutCalls)],
env: bunEnv,
stdout: "pipe",
stderr: "pipe",
lazy: true,
});
expect(await jsonLines(proc.stderr).next()).toEqual({ ret: false });
const [stdout, exitCode] = await Promise.all([proc.stdout.bytes(), proc.exited]);
expect(squash(stdout)).toBe(inCallOrder(stdoutCalls));
expect(exitCode).toBe(0);
});
},
);
12 changes: 12 additions & 0 deletions test/js/web/console/console-log.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -157,6 +157,18 @@ NamedError: console.error a named error
`);
});

it("console.error() and console.warn() with no arguments print their newline on stderr", async () => {
await using proc = spawn({
cmd: [bunExe(), "-e", "console.error(); console.warn(); console.log();"],
env: bunEnv,
stdout: "pipe",
stderr: "pipe",
});
const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]);
expect({ stdout, stderr }).toEqual({ stdout: "\n", stderr: "\n\n" });
expect(exitCode).toBe(0);
});

it("console.log with SharedArrayBuffer", () => {
// console.log(x) === Bun.inspect(x) + "\n" written to stdout.
expect(Bun.inspect(new ArrayBuffer(0))).toBe("ArrayBuffer(0) []");
Expand Down
Loading