Skip to content
Open
Show file tree
Hide file tree
Changes from 6 commits
Commits
Show all changes
32 commits
Select commit Hold shift + click to select a range
20de013
fs: buffer createWriteStream(path) writes through a FileSink
robobun Jul 26, 2026
4727433
fs: coalesce createWriteStream(path) writes via synchronous writeSync…
robobun Jul 26, 2026
1a65e64
[autofix.ci] apply automated fixes
autofix-ci[bot] Jul 26, 2026
ce48dcd
trim comments
robobun Jul 26, 2026
b240969
gate the writeSync path on fd == null so caller-supplied pipe fds kee…
robobun Jul 26, 2026
396cce8
trim comment
robobun Jul 26, 2026
1c951e8
recheck destroyed before the deferred writeAllSync callback
robobun Jul 26, 2026
66df641
fstat the opened fd and keep the thread-pool path for non-regular files
robobun Jul 26, 2026
3817d4a
route the first _writev batch through fs.write when the patch is dete…
robobun Jul 26, 2026
78c733f
drain the FIFO in the thread-pool-path test so the child exits cleanly
robobun Jul 26, 2026
bf0a1c1
fs: batch createWriteStream writes via _writev on the thread pool
robobun Jul 26, 2026
b59aa0c
trim comment
robobun Jul 26, 2026
d811091
fall back to writevAll for a writev-only custom fs; fix stale test co…
robobun Jul 26, 2026
d5c0711
fs: back createWriteStream(path) with a FileSink that adopts the fd
robobun Jul 27, 2026
2e70c65
dup a pollable caller-supplied fd so each FileSink keeps its own epol…
robobun Jul 27, 2026
973474a
trim comment
robobun Jul 27, 2026
a3883a9
propagate sink.write() rejections; gate FileSink on autoClose; drop s…
robobun Jul 27, 2026
cb26f1f
concat _writev chunks into one sink.write; await sink.end() in close()
robobun Jul 27, 2026
b47349c
add FileSink.writev and use it in WriteStream _writev; fire on_close …
robobun Jul 27, 2026
075d741
PosixStreamingWriter::writev: build an iovec and call pwritev2(RWF_NO…
robobun Jul 27, 2026
5cd43a3
trim comment
robobun Jul 27, 2026
b40bde7
[autofix.ci] apply automated fixes
autofix-ci[bot] Jul 27, 2026
3e1312a
route try_write's File arm through write_nonblocking
robobun Jul 27, 2026
3731912
review fixes: iovec cursor; Backpressure in default writev_bytes; Win…
robobun Jul 27, 2026
0b699d1
[autofix.ci] apply automated fixes
autofix-ci[bot] Jul 27, 2026
b9e3848
on_auto_flush: keep flushing when done with pending data; move close_…
robobun Jul 27, 2026
d6ff1d1
clippy: SAFETY comment placement; use ? for dup_with_flags
robobun Jul 27, 2026
fec2342
Windows: dup borrowed pipe/tty before uv_pipe_open so end() can close…
robobun Jul 27, 2026
714bdd3
shorten dup-rationale comments
robobun Jul 27, 2026
f9d4f11
test: wire server 'error' via events.once for the named-pipe listen
robobun Jul 27, 2026
4531be8
writev: route FileType::Pipe through writev_buffered so a blocking pi…
robobun Jul 27, 2026
0fe7392
js_writev: acquire this after accessor JS runs; setup() error path: l…
robobun Jul 27, 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
44 changes: 42 additions & 2 deletions src/js/internal/fs/streams.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@
import type { FileSink } from "bun";
const { Readable, Writable, finished } = require("node:stream");
const fs: typeof import("node:fs") = require("node:fs");
const { read, write, fsync, writev } = fs;
const { read, write, fsync, writev, writeSync } = fs;
const { FileHandle, kRef, kUnref, kFd } = (fs.promises as any).$data as {
FileHandle: { new (): FileHandle };
readonly kRef: unique symbol;
Expand Down Expand Up @@ -37,6 +37,7 @@ const { validateInteger, validateInt32, validateFunction } = require("internal/v

const kIsPerformingIO = Symbol("kIsPerformingIO");
const kIoDone = Symbol("kIoDone");
const kSyncWrite = Symbol("kSyncWrite");
// Bun supports a fast path for `createWriteStream("path.txt")` where instead of
// using `node:fs`, `Bun.file(...).writer()` is used instead.
const kWriteStreamFastPath = Symbol("kWriteStreamFastPath");
Expand Down Expand Up @@ -449,7 +450,9 @@ function WriteStream(this: FSStream, path: string | null, options?: any): void {
if (!write) this._write = null;
if (!writev) this._writev = null;
} else {
this._writev = undefined;
// Only when we open the path ourselves: a caller-supplied fd may be a pipe.
if (!fastPath && fd == null && start === undefined) this[kSyncWrite] = true;
else this._writev = undefined;
$assert(this[kFs].write, "assuming user does not delete fs.write!");
}

Expand Down Expand Up @@ -571,7 +574,40 @@ function writevAll(chunks, size, pos, cb, retries = 0) {
});
}

function writeAllSync(stream, data, cb) {
if (stream.destroyed) return cb($ERR_STREAM_DESTROYED("write"));
let retries = 0;
try {
let offset = 0;
let size = data.length;
while (size > 0) {
const n = writeSync(stream.fd, data, offset, size);
stream.bytesWritten += n;
offset += n;
size -= n;
retries = n ? 0 : retries + 1;
if (retries > 5) return cb(new Error("write failed"));
}
} catch (e) {
return cb(e);
}
process.nextTick(cb, null);
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Outdated

function syncWriteEnabled(stream) {
if (stream[kSyncWrite] !== true) return false;
// Honour a monkey-patched fs.write/fs.writev by falling back.
if (fs.write !== write || fs.writev !== writev) {
stream[kSyncWrite] = false;
stream._writev = undefined;
return false;
}
return true;
}
Comment thread
robobun marked this conversation as resolved.
Outdated

function _write(data, encoding, cb) {
if (syncWriteEnabled(this)) return writeAllSync(this, data, cb);

const fileSink = this[kWriteStreamFastPath];

if (fileSink && fileSink !== true) {
Expand Down Expand Up @@ -710,6 +746,10 @@ writeStreamPrototype._writev = function (data, cb) {
size += chunk.length;
}

if (syncWriteEnabled(this)) {
return writeAllSync(this, len === 1 ? chunks[0] : Buffer.concat(chunks, size), cb);
}

const fileSink = this[kWriteStreamFastPath];
if (fileSink && fileSink !== true) {
const maybePromise = fileSink.write(Buffer.concat(chunks));
Expand Down
96 changes: 96 additions & 0 deletions test/js/node/fs/fs.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ import {
tempDirWithFiles,
tmpdirSync,
} from "harness";
import { once } from "node:events";
import fs, {
closeSync,
constants,
Expand Down Expand Up @@ -3671,6 +3672,101 @@ describe("createWriteStream", () => {
}
});
});

async function writeLines(ws: fs.WriteStream, n: number, line: string | Buffer) {
for (let i = 0; i < n; i++) {
if (!ws.write(line)) await once(ws, "drain");
}
await new Promise<void>((resolve, reject) => ws.end(err => (err ? reject(err) : resolve())));
await once(ws, "close");
}
Comment thread
claude[bot] marked this conversation as resolved.

it.skipIf(!isLinux)("coalesces many small writes instead of dispatching one syscall per chunk", async () => {
const syscw = () => +readFileSync("/proc/self/io", "utf8").match(/syscw: (\d+)/)![1];
const N = 5000;
const line = Buffer.alloc(81, "x");
using dir = tempDir("ws-coalesce", {});
const streamPath = join(String(dir), "out.txt");

const ws = createWriteStream(streamPath);
await once(ws, "ready");
const fd = ws.fd as number;
expect(fd).toBeGreaterThan(0);

const before = syscw();
await writeLines(ws, N, line);
const writeSyscalls = syscw() - before;

expect({
size: statSync(streamPath).size,
bytesWritten: ws.bytesWritten,
fdClosed: (() => {
try {
fstatSync(fd);
return false;
} catch {
return true;
}
})(),
}).toEqual({ size: N * line.length, bytesWritten: N * line.length, fdClosed: true });
Comment thread
claude[bot] marked this conversation as resolved.

// Without coalescing each chunk is a separate thread-pool dispatch (one
// write(2) to the file plus one 8-byte eventfd wake), so 5000 chunks is
// ~10000 write syscalls. Coalescing into one writeSync per drain cycle
// brings it down to tens.
Comment thread
robobun marked this conversation as resolved.
Outdated
expect(writeSyscalls).toBeLessThan(N / 4);
});

it.skipIf(!isLinux)("holds a single fd for the stream's lifetime", async () => {
const countFds = () => fs.readdirSync("/proc/self/fd").length;
using dir = tempDir("ws-fd-count", {});
const before = countFds();
const ws = createWriteStream(join(String(dir), "out.txt"));
await once(ws, "ready");
expect(countFds() - before).toBe(1);
ws.write("x");
await new Promise<void>((resolve, reject) => ws.end(err => (err ? reject(err) : resolve())));
await once(ws, "close");
expect(countFds() - before).toBe(0);
});
Comment thread
robobun marked this conversation as resolved.

it("many small writes produce byte-exact output and bytesWritten", async () => {
const N = isDebug ? 2000 : 10000;
const chunk = "\u00e9#"; // 2-byte UTF-8 char + 1 ASCII byte => 3 bytes/chunk
const byteLen = Buffer.byteLength(chunk);
using dir = tempDir("ws-bytes", {});
const streamPath = join(String(dir), "out.txt");

const ws = createWriteStream(streamPath);
await writeLines(ws, N, chunk);

expect({
size: statSync(streamPath).size,
bytesWritten: ws.bytesWritten,
head: readFileSync(streamPath, "utf8").slice(0, chunk.length * 3),
}).toEqual({ size: N * byteLen, bytesWritten: N * byteLen, head: chunk + chunk + chunk });
});

it("falls back to fs.write when it has been monkey-patched", async () => {
using dir = tempDir("ws-patch", {});
const streamPath = join(String(dir), "out.txt");
const ws = createWriteStream(streamPath);
const original = fs.write;
let calls = 0;
// @ts-ignore
fs.write = function () {
calls++;
return original.apply(fs, arguments);
};
try {
ws.write("hello");
await new Promise<void>((resolve, reject) => ws.end(err => (err ? reject(err) : resolve())));
await once(ws, "close");
} finally {
fs.write = original;
}
expect({ calls, contents: readFileSync(streamPath, "utf8") }).toEqual({ calls: 1, contents: "hello" });
});
});

describe("fs/promises", () => {
Expand Down
Loading