Skip to content

fs.Utf8Stream: suppress post-reopen 'drain' while a write is in flight - #35824

Closed
robobun wants to merge 7 commits into
mainfrom
farm/4020cace/utf8stream-reopen-stale-drain
Closed

robobun wants to merge 7 commits into
mainfrom
farm/4020cace/utf8stream-reopen-stale-drain

Conversation

@robobun

@robobun robobun commented Jul 25, 2026 •

Copy link
Copy Markdown
Collaborator

test/js/node/test/parallel/test-fastutf8stream-reopen.js has been flaking across CI (32 of the last 400 builds, 190 per-lane occurrences, heaviest on linux 25.04 x64/aarch64 and alpine 3.23):

AssertionError: Expected values to be strictly equal:
+ ''
- 'after reopen\n'
      at test/js/node/test/parallel/test-fastutf8stream-reopen.js:52:22

Cause

After reopen(), #openFile's fileOpened callback emits 'ready' synchronously. A 'ready' listener that calls write() starts an async fs.write (setting #writing = true) and then typically registers once('drain', ...) to read the file once the write lands. fileOpened then continues:

if ((!this.#writing && this.#len > this.#minLength) || this.#flushPending) {
  this.#actualWrite();
} else if (reopening) {
  process.nextTick(() => this.emit("drain"));
}

Neither arm accounts for #writing having just been set inside the synchronous emit('ready'):

  • The else if (reopening) arm schedules process.nextTick(() => emit('drain')) unconditionally. That nextTick races the threadpool worker executing the write() syscall; when the worker is delayed (loaded CI box), the nextTick wins, the 'drain' listener fires early, and readFile(dest) observes an empty file. This is the CI flake.
  • The || this.#flushPending disjunct calls #actualWrite() even though one is already in flight. With minLength > 0 and a flush(cb) issued while the reopen's fs.open is pending, fileOpened dispatches a second fs.write of the same #writingBuf, so the data is appended twice and #release fires twice.

Node's upstream lib/internal/streams/fast-utf8-stream.js has the identical code; it happens not to flake in Node's CI because libuv's threadpool worker almost always completes the small write() before the main thread drains the nextTick queue.

Fix

Hoist a single !this.#writing guard above both arms. When a write is in flight, #release drives the state machine (emitting 'drain', continuing buffered writes, and servicing #flushPending) once that write completes. The no-write-in-'ready' path (block 5 of the upstream test) is unchanged: #writing stays false and the post-reopen 'drain' still fires.

Verification

test/js/node/fs/fast-utf8-stream.test.ts adds three deterministic tests via the fs override hook:

  • holds the post-reopen fs.write until after nextTick and asserts 'drain' only fires once the write callback is delivered (fails on main with events: ["ready", "drain"] before the callback);
  • asserts reopen still emits 'drain' when nothing is written in 'ready';
  • counts fs.write dispatches across a reopen with minLength: 4 and a pending flush() (fails on main with ["hello\n", "hello\n"]).
bun bd test test/js/node/fs/fast-utf8-stream.test.ts         # 3 pass, 5/5 runs
bun bd test/js/node/test/parallel/test-fastutf8stream-reopen.js   # 30/30 runs
# all 14 test-fastutf8stream-*.js pass

[review] gate passed · iteration 1 · 2 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/node/fs/fast-utf8-stream.test.ts"
bun test v1.4.0 (f36438206)

test/js/node/fs/fast-utf8-stream.test.ts:
71 |     // Give any such nextTick a chance to run before we assert.
72 |     await new Promise<void>(r => process.nextTick(r));
73 | 
74 |     // The bug: 'drain' has already fired here even though fs.write's callback
75 |     // has not. With the fix, 'drain' is held back until #release runs.
76 |     expect({ events, writeCallbackDelivered }).toEqual({ events: ["ready"], writeCallbackDelivered: false });
                                                    ^
error: expect(received).toEqual(expected)

  {
    "events": [
      "ready",
+     "drain",
    ],
    "writeCallbackDelivered": false,
  }

- Expected  - 0
+ Received  + 1

      at <anonymous> (/workspace/bun/test/js/node/fs/fast-utf8-stream.test.ts:76:48)
(fail) fs.Utf8Stream reopen > does not emit 'drain' while a write started in the 'ready' handler is still in flight [545.41ms]
155 |     stream.flush(() => {});
156 | 
157 |     await readyHandled.promise;
158 | 
... (truncated)

release without fix: BUILD FAILED (no junit output)
bun test v1.4.0-canary.1 (1498d7b77)

test/js/node/fs/fast-utf8-stream.test.ts:

# Unhandled error between tests
-------------------------------
SyntaxError: Export named 'Utf8Stream' not found in module 'node:fs'.
-------------------------------


 0 pass
 1 fail
 1 error
Ran 1 test across 1 file. [230.00ms]
__F:-1:S:0
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/fs/fast-utf8-stream.test.ts"
bun test v1.4.0 (f36438206)

test/js/node/fs/fast-utf8-stream.test.ts:
(pass) fs.Utf8Stream reopen > does not emit 'drain' while a write started in the 'ready' handler is still in flight [474.52ms]
(pass) fs.Utf8Stream reopen > still emits 'drain' after reopen when no write happens in 'ready' [301.77ms]
(pass) fs.Utf8Stream reopen > does not double-dispatch fs.write when flush() is pending across reopen [275.37ms]

 3 pass
 0 fail
 10 expect() calls
Ran 3 tests across 1 file. [4.11s]
__F:0:S:0

release with fix: all passed
$ bun scripts/build.ts --profile=release
[configured] bun-profile → bun (stripped) in 1206ms (unchanged)
ninja: Entering directory `/workspace/bun/build/release'
[1/1188] codegen tinycc
[2/1188] gen bake.{client,server,error}.js
-> bake.client.js, bake.server.js, bake.error.js
[3/1188] cc obj/vendor/zlib/deflate_huff.c.o
[4/1188] cc obj/vendor/zlib/adler32.c.o
[5/1188] fetch hdrhistogram
[hdrhistogram] up to date
[6/1188] cc obj/vendor/zlib/deflate_medium.c.o
[7/1188] cc obj/vendor/zlib/deflate_quick.c.o
[8/1188] cc obj/vendor/zlib/deflate_rle.c.o
[9/1188] cc obj/vendor/zlib/deflate_slow.c.o
[10/1188] fetch lsqpack
[lsqpack] up to date
[11/1188] fetch mimalloc
[mimalloc] up to date
[12/1188] fetch lshpack
[lshpack] up to date
[13/1188] cc obj/vendor/zlib/deflate_fast.c.o
[14/1188] cc obj/vendor/zlib/crc32_braid_comb.c.o
[15/1188] cc obj/vendor/zlib/insert_string.c.o
[16/1188] cc obj/vendor/zlib/insert_string_roll.c.o
[17/1188] fetch boringssl
[boringssl] up to date
[18/1188] cc obj/vendor/zlib/functable.c.o
[19/1188] cc obj/vendor/zlib/inftrees.c.o
[20/1188] fetch lolhtml
[lolhtml] up to date
[21/1188] cc obj/vendor/zlib/uncompr.c.o
[22/1188] cc obj/vendor/zlib/de
... (truncated)
diff hotspot
src/js/internal/streams/fast-utf8-stream.ts |  10 +-
 test/js/node/fs/fast-utf8-stream.test.ts    | 167 ++++++++++++++++++++++++++++
 2 files changed, 173 insertions(+), 4 deletions(-)

gate history · 2 passed · 0 rejected · iteration 1

evidence per changed file
file                                         reads  edits  tests
src/js/internal/streams/fast-utf8-stream.ts      4      6      0
test/js/node/fs/fast-utf8-stream.test.ts         4      6      0

After reopen(), fileOpened emits 'ready' synchronously. A listener on
'ready' that calls write() starts an async fs.write and typically
registers once('drain', ...) to read the file once that write lands.
fileOpened then unconditionally scheduled process.nextTick(emit('drain'))
whenever reopening was true, even though #writing had just been set by
the ready-handler's write(). That stale drain raced the threadpool
worker running the write() syscall; on a loaded box the nextTick would
win and the drain listener observed an empty file.

This is the test-fastutf8stream-reopen.js flake seen on 32/400 recent CI
builds (mostly linux aarch64). Node's upstream has the same check but
doesn't flake because its threadpool worker almost always finishes the
write() before the nextTick runs.

Only schedule the post-reopen drain when no write is in flight. #release
emits drain once the pending write completes, which is what callers
actually want.
@coderabbitai

coderabbitai Bot commented Jul 25, 2026 •

Copy link
Copy Markdown
Contributor

Review Change Stack

Walkthrough

Changes

Utf8Stream reopen behavior

Layer / File(s) Summary
Condition reopen drain on writing state
src/js/internal/streams/fast-utf8-stream.ts
Reopen completion schedules "drain" only when no write is active.
Cover delayed and write-free reopen cases
test/js/node/fs/fast-utf8-stream.test.ts
Tests verify delayed drain emission during writes, resulting file contents, stream closure, and drain emission when no write occurs during ready.

Possibly related PRs

🚥 Pre-merge checks | ✅ 4
✅ Passed checks (4 passed)
Check name Status Explanation
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
Title check ✅ Passed The title clearly and concisely summarizes the main fix to post-reopen drain behavior.
Description check ✅ Passed The description covers the PR's purpose and verification details, albeit with extra sections.

Comment @coderabbitai help to get the list of available commands.

@robobun

robobun commented Jul 25, 2026 •

Copy link
Copy Markdown
Collaborator Author
Updated 7:06 PM PT - Jul 25th, 2026

⏳ @robobun, your commit f364382 is still building in Build #81896, but has 1 failures so far (All Failures):

@claude claude Bot left a comment

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.

Beyond the inline nit, I traced the other reopen paths: block 5 of test-fastutf8stream-reopen.js (reopen → once('drain') with no write in 'ready') still fires because #writing stays false; sync: true is unaffected since 'ready' is deferred to nextTick there, so #writing is still false at the guard; and the minLength > 0 buffered-below-threshold case keeps its prior behavior.

Extended reasoning...

Traced the added !this.#writing guard against the other consumers of the post-reopen 'drain': block 5 of the flaking Node parallel test, sync-mode reopen, and the buffered-below-minLength path all reach the guard with #writing === false and behave identically to before. The only behavioral change is suppressing the premature nextTick 'drain' when a write was started synchronously inside the 'ready' handler, which #release then emits once the write lands.

Comment thread test/js/node/fs/fast-utf8-stream.test.ts
Comment thread src/js/internal/streams/fast-utf8-stream.ts Outdated

@coderabbitai coderabbitai Bot left a comment

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.

Actionable comments posted: 1

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@test/js/node/fs/fast-utf8-stream.test.ts`:
- Around line 8-126: Mark the independent filesystem-based tests in the
“fs.Utf8Stream reopen” suite as concurrent using the test framework’s concurrent
test API. Update both test declarations while preserving their existing setup,
assertions, and cleanup behavior.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: ASSERTIVE

Plan: Pro

Run ID: 6893e21f-c72b-4103-9e5e-86601ad537c3

📥 Commits

Reviewing files that changed from the base of the PR and between 916492f and 7f7ff6b.

📒 Files selected for processing (2)
  • src/js/internal/streams/fast-utf8-stream.ts
  • test/js/node/fs/fast-utf8-stream.test.ts

Comment thread test/js/node/fs/fast-utf8-stream.test.ts Outdated
Comment thread src/js/internal/streams/fast-utf8-stream.ts Outdated
The #flushPending disjunct had the same post-'ready' hazard as the
reopening arm: with minLength > 0, a flush(cb) while opening sets
#flushPending, and if a write() inside the sync 'ready' emit starts an
fs.write, fileOpened would call #actualWrite a second time on the same
#writingBuf. Hoist a single !#writing guard above both arms so neither
can fire while a write is already in flight; #release drives the state
machine once the in-flight write completes.

Adds a deterministic test that counts fs.write dispatches across a
reopen with a pending flush.

@claude claude Bot left a comment

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.

LGTM — both earlier findings are addressed (the writing assertion is now captured outside the callback, and the !this.#writing guard is hoisted over both arms with a dedicated test).

What was reviewed:

  • Traced fileOpened after the hoist: when #writing is set inside emit('ready'), #release on that write drives both the 'drain' emit and the pending-flush path, so nothing is dropped.
  • Confirmed the !#writing no-write-in-ready path still schedules the post-reopen 'drain' (test 2 covers this; block 5 of the upstream node test unaffected).
  • Walked test 3's sequencing (minLength: 4, flush() while opening, write() in 'ready') — on main it double-dispatches, with the guard writes is ["hello\n"]; the append-mode file-content assertion is correct for a same-path reopen.
Extended reasoning...

Overview

Two files: a 7-line control-flow change in src/js/internal/streams/fast-utf8-stream.ts (#openFile's fileOpened callback) hoisting a single !this.#writing guard above the post-'ready' dispatch, and a new test/js/node/fs/fast-utf8-stream.test.ts with three deterministic tests using the constructor's fs override hook to control write timing.

The fix addresses a real CI flake (32/400 builds) in test-fastutf8stream-reopen.js where a process.nextTick(() => emit('drain')) scheduled after reopen raced the threadpool worker executing a write() started synchronously inside the 'ready' handler. The hoisted guard also fixes the sibling || this.#flushPending arm, which could double-dispatch #actualWrite() under minLength > 0 — that was raised in my earlier review and folded into this PR with its own test.

Security risks

None. This is internal event-ordering in a Node-compat stream class; no untrusted input parsing, no auth/crypto/permissions surface.

Level of scrutiny

Moderate. The source change is tiny and mechanical, but it alters the state machine of a shared stream class, so I traced every case where the old code took an action and the new code doesn't:

  • #writing && #flushPending: old code called #actualWrite() (the double-dispatch bug); new code skips, and #release on the in-flight write reaches the drain branch which fires #callFlushCallbackOnDrain's onDrain — flush is still serviced.
  • #writing && reopening: old code scheduled a premature 'drain'; new code skips, and #release emits 'drain' after the write lands (#reopening was already cleared to false earlier in fileOpened, so #release reaches the else/drain branch).
  • !#writing cases are byte-identical to before.

Since #writing is set to false immediately before emit('ready'), the only way it's true at the guard is via a 'ready' listener calling write(), which always leaves a #release pending — so the skipped work is never lost.

Other factors

  • All prior review threads (my two, comment-cop, coderabbit) are resolved by follow-up commits d92c8b0, 7f7ff6b, a607d2f, 7e523f0.
  • Tests follow harness conventions: tempDir with using, describe.concurrent (independent temp dirs), no sleeps — the race is made deterministic via the fs.write override holding the callback until after a process.nextTick has drained. Assertions are outside event callbacks per REVIEW.md.
  • No pre-existing bun:test file for Utf8Stream (only ported node parallel tests), so the new file location is correct.
  • PR description confirms all 14 test-fastutf8stream-*.js upstream tests still pass and the flaking test passed 30/30.

@claude claude Bot left a comment

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.

LGTM. The hoisted !this.#writing guard is the right fix for both arms, and both prior review threads (assertion-in-callback, #flushPending double-dispatch) are addressed. The remaining inline nit about using stream for fd cleanup on assertion failure is worth applying but not blocking.

Extended reasoning...

Overview

Six-line change in src/js/internal/streams/fast-utf8-stream.ts (fileOpened): the post-emit('ready') dispatch previously read if ((!this.#writing && this.#len > this.#minLength) || this.#flushPending) { #actualWrite() } else if (reopening) { nextTick(emit('drain')) }. Neither the #flushPending disjunct nor the reopening arm accounted for a write() inside the synchronous 'ready' listener having already set #writing = true. The PR hoists a single if (!this.#writing) above both arms. When a write is in flight, #release already drives the rest of the state machine (continues buffered writes, services #flushPending via the 'drain' listener registered in #callFlushCallbackOnDrain, emits 'drain'). When #writing is false the transformed condition is identical to the original, so the no-write-in-'ready' path is unchanged. In sync mode 'ready' is deferred via process.nextTick, so #writing is always false at this point and behavior is unchanged there too.

A new test file test/js/node/fs/fast-utf8-stream.test.ts adds three deterministic tests using the fs override hook: (1) holds the post-reopen fs.write past nextTick and asserts 'drain' only fires after the write callback; (2) asserts reopen with no write in 'ready' still emits 'drain'; (3) counts fs.write dispatches with minLength: 4 and a flush() pending across reopen. The evidence block shows tests 1 and 3 fail on main and all three pass with the fix.

Security risks

None. Internal stream state-machine ordering; no untrusted input, no auth/crypto/permissions.

Level of scrutiny

Moderate — this is a Node-compat internal stream (Utf8Stream, ported from SonicBoom) used for logging-style workloads. The change is small, the root cause is well explained, and I traced each arm: with #writing true after 'ready', skipping both arms is safe because #release (already the sole driver once a write is in flight) handles #flushPending via the registered 'drain' listener and emits 'drain' itself; #reopening was already cleared to false before emit('ready'), so #release correctly takes the normal drain path rather than re-reopening. I also checked end()/destroy()/reopen() called from inside 'ready' — each either sets #destroyed (caught by the earlier guard) or starts a write/open that #release/the next fileOpened will complete.

Other factors

Both of my earlier inline comments were addressed (d92c8b0 moved the assertion out of the event callback; 7e523f0 hoisted the guard over the #flushPending arm and added the third test). The comment-cop and CodeRabbit nits were also addressed. No CODEOWNERS entry for these paths. The one remaining nit this run — stream.end() cleanup placed after the assertions instead of using stream — is a test-hygiene point that only matters on assertion failure; it doesn't affect the fix's correctness and can be a one-token follow-up.

Comment thread test/js/node/fs/fast-utf8-stream.test.ts Outdated

@claude claude Bot left a comment

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.

LGTM — the hoisted !this.#writing guard is the minimal correct fix for both arms of the post-'ready' dispatch, and all prior review feedback has been addressed.

What was reviewed:

  • Traced #release to confirm it drives 'drain' emission and #flushPending servicing when the guard skips both arms — no lost drain, no lost flush callback.
  • Verified the no-write-in-'ready' path is unchanged (#writing stays false → nextTick drain still fires; covered by test 2).
  • Checked the divergence from upstream Node's identical unguarded code — it's a genuine race in the state machine, documented in the PR body and the test's describe block; not a Bun-layer workaround.
Extended reasoning...

Overview

The PR changes 6 lines in src/js/internal/streams/fast-utf8-stream.ts (fileOpened's post-emit('ready') dispatch) and adds a new test file with three deterministic tests. The production change hoists a single !this.#writing guard above both the #actualWrite() arm and the process.nextTick(() => emit('drain')) arm, so neither fires when a write() inside the synchronous 'ready' handler has already started an async fs.write. #release (the write completion callback) already handles both cases correctly: it emits 'drain' when the buffer drains, and the once('drain', onDrain) registered by #callFlushCallbackOnDrain services the pending flush.

Security risks

None. This is internal state-machine sequencing in a Node-compat stream class; no untrusted input parsing, no auth/crypto/permissions surface.

Level of scrutiny

Moderate. It's a Node-compat internal (fs.Utf8Stream, ported from Node/SonicBoom), so behavioral changes matter — but the change strictly narrows when a redundant 'drain'/#actualWrite() fires, and the retained path (#release) already produces the correct events. The PR description documents that upstream Node has the identical unguarded code; this is a real race (not a Bun-specific symptom), so the "suspect a different layer" rule doesn't apply. The file is not in CODEOWNERS.

Other factors

All five prior review threads are resolved: my three nits (assertion inside event callback → captured to local; sibling #flushPending arm → guard hoisted over both arms with a dedicated test; fd cleanup on assertion failure → using stream), comment-cop (explanatory comment dropped; rationale in commit message + test describe block), and coderabbit (describe.concurrent). The PR evidence shows both new failure modes reproduce on main (ASAN + release) and pass with the fix; the existing 14 test-fastutf8stream-*.js upstream tests still pass. Tests use tempDir, using, the fs override hook for determinism (no sleeps), and assert exact event ordering and file contents.

@robobun

robobun commented Jul 26, 2026

Copy link
Copy Markdown
Collaborator Author

CI on f364382 (build 81896): the new test/js/node/fs/fast-utf8-stream.test.ts and test-fastutf8stream-reopen.js passed on every lane.

The remaining red is unrelated to this diff:

This change is a 6-line hoist in a bundled JS module plus a test file; neither touches worker threads or JSC exception handling. Ready for merge.

@robobun

robobun commented Jul 29, 2026

Copy link
Copy Markdown
Collaborator Author

Still the top non-threshold flake: test-fastutf8stream-reopen.js flaked in 121 of the last 400 CI builds (Jul 28-29) across ubuntu/alpine/debian/mac/windows. Closed the duplicate #36412 in favor of this one. The windows-x64 build failure above looks unrelated to the diff (cpp build step); might be worth a rebase to re-run CI.

@robobun

robobun commented Aug 8, 2026

Copy link
Copy Markdown
Collaborator Author

Superseded by #37200, which carries the same guard (drain only when no write is in flight after reopen) with a deterministic regression test. Closing in favor of that PR.

@robobun robobun closed this Aug 8, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant