Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
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
5 changes: 4 additions & 1 deletion src/js/internal/streams/fast-utf8-stream.ts
Original file line number Diff line number Diff line change
Expand Up @@ -452,9 +452,12 @@
}

// start
if ((!this.#writing && this.#len > this.#minLength) || this.#flushPending) {
this.#actualWrite();
} else if (reopening) {
} else if (reopening && !this.#writing) {

Check warning on line 457 in src/js/internal/streams/fast-utf8-stream.ts

View check run for this annotation

Claude / Claude Code Review

flushPending clause not guarded on !#writing after emit('ready')

The sibling `|| this.#flushPending` clause on this same conditional has the identical hazard and remains unguarded — if a flush is pending across a reopen and a `'ready'` listener starts an async write, `fileOpened` calls `#actualWrite()` a second time on the in-flight `#writingBuf`, issuing two concurrent `fs.write` calls (duplicate bytes + double `#release`). Hoisting `!this.#writing &&` around the whole disjunction fixes both arms. This is pre-existing (matches upstream Node) and needs a narr
Comment on lines 455 to +457

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.

🟡 The sibling || this.#flushPending clause on this same conditional has the identical hazard and remains unguarded — if a flush is pending across a reopen and a 'ready' listener starts an async write, fileOpened calls #actualWrite() a second time on the in-flight #writingBuf, issuing two concurrent fs.write calls (duplicate bytes + double #release). Hoisting !this.#writing && around the whole disjunction fixes both arms. This is pre-existing (matches upstream Node) and needs a narrower trigger (minLength > 0 + flush(cb) pending across a reopen), so it's a nit, not a blocker.

Extended reasoning...

What the bug is

The PR correctly identifies that code in fileOpened running after the synchronous emit('ready') cannot assume #writing is still false, because a 'ready' listener may have called write() and started an async fs.write. It guards the else if (reopening) branch accordingly. However, the immediately preceding branch on the same line has the identical hazard on its || this.#flushPending clause, and that clause is not gated on !this.#writing:

if ((!this.#writing && this.#len > this.#minLength) || this.#flushPending) {
  this.#actualWrite();
} else if (reopening && !this.#writing) {   // <- PR adds guard here

If #flushPending is true when fileOpened runs and a 'ready' listener has just started an async write (#writing = true), the first branch is taken via || this.#flushPending and #actualWrite() is called again. #actualWriteUtf8 does this.#writingBuf ||= this.#bufs.shift() || "" — since #writingBuf is already the non-empty in-flight payload, the ||= keeps it and #fsWrite() issues a second concurrent fs.write(fd, sameBuf). Both completions invoke the shared boundRelease.

Step-by-step proof (async mode, minLength > 0)

  1. new Utf8Stream({ dest, sync: false, minLength: 8 }); write("abc") buffers (3 < 8, no write).
  2. flush(cb) → #flushUtf8: minLength > 0, so #callFlushCallbackOnDrain(cb) sets #flushPending = true and registers once('drain', onDrain); then #actualWrite() starts an async fs.write (#writing = true).
  3. reopen() → #reopening = true; returns early because #writing.
  4. The fs.write from step 2 completes → #release sees #reopening, takes the reopen branch without emitting 'drain' (so onDrain never fires and #flushPending stays true), and calls reopen() → #openFile.
  5. fs.open completes → fileOpened: clears #reopening/#opening/#writing, then synchronously emit('ready').
  6. User's once('ready') listener calls write("after reopen\n") (13 ≥ 8) → #writeUtf8 hits !this.#writing && this.#len >= this.#minLength → #actualWrite() → #writing = true, #writingBuf = "after reopen\n", async fs.write in flight.
  7. Control returns to fileOpened. (!this.#writing && …) is false, but || this.#flushPending is true → #actualWrite() runs again → #writingBuf ||= … keeps "after reopen\n" → second #fsWrite() on the same fd with the same payload.

Why existing code doesn't prevent it

  • #flushPending is set only in #callFlushCallbackOnDrain and cleared only by its 'drain'/'error' listeners. #release's #reopening branch does not emit 'drain', and #openFile clears #asyncDrainScheduled but not #flushPending, so the flag survives into fileOpened.
  • #actualWriteUtf8's ||= is designed for the partial-write retry path; it has no way to know a write is already in flight, so it happily re-issues.

Impact

Two concurrent fs.write calls on the same fd in append mode → the payload is written to disk twice. Both completions call boundRelease, so #release runs twice: the first drains #writingBuf and (with #len now 0) emits 'drain'; the second runs against already-cleared state, likely emitting a spurious extra 'drain' and desynchronizing #len bookkeeping.

Fix

Same fix as the PR's, applied to the sibling clause — hoist the guard around the whole disjunction:

if (!this.#writing && (this.#len > this.#minLength || this.#flushPending)) {
  this.#actualWrite();
} else if (reopening && !this.#writing) {

When #writing is already true, #release on the in-flight write will pick up the pending flush via its normal len > this.#minLength / drain path.

Severity

Nit. This is pre-existing (predates the PR and matches upstream Node's fast-utf8-stream.js identically), and the trigger is materially narrower than the bug the PR fixes: it needs minLength > 0, a flush(cb) pending across a reopen, and a 'ready' listener that writes ≥ minLength bytes. The observed CI flake is fully addressed by the PR as-is. Flagging per REVIEW.md's "fix the whole class in the same PR — parallel switch arms" since it's the same hazard on the same conditional the PR edits, and the one-token hoist covers both — but it shouldn't block merging this deflake.

// A 'ready' listener may have called write() synchronously and started
// an async fs.write; emitting 'drain' now would signal completion while
// that write is still in flight. #release emits 'drain' when it lands.
process.nextTick(() => this.emit("drain"));
}
};
Expand Down
68 changes: 68 additions & 0 deletions test/js/node/fs/utf8stream-reopen-drain.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,68 @@
import { expect, test } from "bun:test";
import { bunEnv, bunExe, tempDir } from "harness";

// Deflake for test/js/node/test/parallel/test-fastutf8stream-reopen.js.
//
// After reopen(), fileOpened() emits 'ready' synchronously (async mode). If a
// 'ready' listener calls write(), #actualWrite starts an async fs.write and
// sets #writing=true. fileOpened() must then NOT schedule its post-reopen
// nextTick 'drain': that drain would fire while the write is still in flight,
// so a listener that reads the file on 'drain' observes an empty file. The
// real flake surfaces when Bun's fs.write threadpool task completes after
// nextTick under CI load; here we force it deterministically by deferring the
// write callback with setImmediate.
test("fs.Utf8Stream: reopen does not emit 'drain' while a write started from 'ready' is pending", async () => {
using dir = tempDir("utf8stream-reopen-drain", {});
await using proc = Bun.spawn({
cmd: [
bunExe(),
"-e",
`
const { Utf8Stream } = require("node:fs");
const fs = require("node:fs");
const path = require("node:path");
const assert = require("node:assert");

const dest = path.join(process.env.DIR, "a.log");
let afterReopen = false;
const slowFs = {
write(fd, data, enc, cb) {
if (afterReopen) {
setImmediate(() => fs.write(fd, data, enc, cb));
} else {
fs.write(fd, data, enc, cb);
}
},
};

const stream = new Utf8Stream({ dest, sync: false, fs: slowFs });
assert.ok(stream.write("hello world\\n"));
assert.ok(stream.write("something else\\n"));
const after = dest + "-moved";

stream.once("drain", () => {
fs.renameSync(dest, after);
stream.reopen();
stream.once("ready", () => {
afterReopen = true;
assert.ok(stream.write("after reopen\\n"));
stream.once("drain", () => {
assert.strictEqual(stream.writing, false, "'drain' fired while a write is in flight");
assert.strictEqual(fs.readFileSync(after, "utf8"), "hello world\\nsomething else\\n");
assert.strictEqual(fs.readFileSync(dest, "utf8"), "after reopen\\n");
stream.end();
stream.on("close", () => console.log("PASS"));
});
});
});
`,
],
env: { ...bunEnv, DIR: String(dir) },
stdout: "pipe",
stderr: "pipe",
});
const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]);
expect(stderr).toBe("");
expect(stdout).toBe("PASS\n");
expect(exitCode).toBe(0);
});
Loading