Conversation
writeFast() replaces Writable.prototype.write on fs.WriteStream when the FileSink fast path is enabled, which is also the path backing process.stdout, process.stderr and a child process's stdin. It never consulted the stream state, so a write issued after end() or destroy() skipped the ERR_STREAM_WRITE_AFTER_END / ERR_STREAM_DESTROYED checks: the write callback was invoked with no error, write() returned true, and for process.stdout the bytes were still written to the fd. Hand those writes back to Writable.prototype.write so they are dropped and reported.
|
Warning Review limit reached
Next review available in: 1 minute Enable usage-based reviews in Billing to review now. Otherwise, wait until the next included review is available. How can I continue?After more reviews become available, a review can be triggered using the To avoid repeated limits, reduce automatic review volume by pausing incremental auto-reviews earlier, using label-based review opt-in, excluding WIP or generated PR titles, or requesting reviews manually when the PR is ready. If your team needs uninterrupted high-volume reviews, an organization admin can enable usage-based reviews. How do review limits work?CodeRabbit enforces per-developer PR review limits for each organization. Most developers receive the normal plan review availability. For paid Pro and Pro+ PR reviews, CodeRabbit uses adaptive limits for sustained high-volume activity. When a developer's recent PR review activity reaches the 95th percentile or higher among CodeRabbit users, additional reviews become available more gradually as earlier reviews age out of the rolling window. Please refer docs for additional details. Review details⚙️ Run configurationConfiguration used: Path: .coderabbit.yaml Review profile: ASSERTIVE Plan: Pro Run ID: 📒 Files selected for processing (3)
WalkthroughThis PR adds a guard in the fast-path write function to defer to Node's standard Writable.prototype.write when a stream is already ended or destroyed, and adds tests for child.stdin and process.stdout verifying write-after-end and write-after-destroy error behavior. ChangesFast-path write guard and validation tests
Sequence Diagram(s)sequenceDiagram
participant Caller
participant writeFast
participant WritablePrototypeWrite as Writable.prototype.write
Caller->>writeFast: write(chunk, encoding, callback)
alt stream ended or destroyed
writeFast->>WritablePrototypeWrite: forward write(chunk, encoding, callback)
WritablePrototypeWrite-->>Caller: emit error (ERR_STREAM_WRITE_AFTER_END / ERR_STREAM_DESTROYED), return false
else stream active
writeFast->>writeFast: continue fast-path sink handling
end
Related Issues: None referenced in the provided summary. Related PRs: None referenced in the provided summary. Suggested labels: bug, streams, node-compat, needs-tests Suggested reviewers: None identifiable from the provided information. 🐰 A stream that's ended should not sneak past, Comment |
|
The review run failed with an internal error ( @coderabbitai review |
|
✅ Action performedReview finished.
|
|
This PR may be a duplicate of:
🤖 Generated with Claude Code |
There was a problem hiding this comment.
LGTM — minimal guard that delegates write-after-end/destroy back to the standard Writable path; the inline nit is just a stale test comment.
Extended reasoning...
Overview
This PR adds a 4-line guard to writeFast() in src/js/internal/fs/streams.ts: when writableEnded || destroyed, delegate the write back to Writable.prototype.write so the standard ERR_STREAM_WRITE_AFTER_END / ERR_STREAM_DESTROYED reporting runs instead of silently writing through to the FileSink. The rest of the diff is four new tests covering child.stdin (write-after-end with and without callback, write-after-destroy) and process.stdout (write-after-end via subprocess).
Security risks
None. This is Node-compat error-reporting behavior for writable streams; no untrusted input parsing, auth, crypto, or filesystem-path handling is touched. The change strictly narrows what the fast path accepts (rejecting writes after end/destroy) rather than accepting anything new.
Level of scrutiny
Moderate — writeFast is on the hot path for process.stdout/stderr and child-process stdin, so a mistake here would be very visible. But the runtime change is tiny and conservative: it reuses the exact delegation pattern already present one line above (writablePrototypeWrite.$call for the monkey-patch-defense case), and only fires when the stream is already ended/destroyed — a state where the previous behavior was clearly wrong (bytes written after end()). The two added property reads (writableEnded, destroyed) are standard Writable prototype getters backed by _writableState bitflags, so per-write overhead is negligible. writableEnded maps to state.ending, which is exactly what Writable.prototype.write checks for ERR_STREAM_WRITE_AFTER_END, so the guard and the delegated check agree.
Other factors
- The PR description includes a clear repro table vs Node v26 and confirms all four new tests fail on 1.4.0 and pass with the change, plus no new failures across the fs/child_process/process/tty/stream/console suites and the relevant node-harness tests.
- The one issue the bug hunt surfaced is a nit: a test comment at child-process-stdio.test.js:136 says the stream will emit
'error', but the assertion below correctly expectserrorEventsto be empty (because autoDestroy has already run by the time the post-finishwrite happens, soerrorOrDestroyshort-circuits). The assertion is right; only the comment wording is stale. This does not affect test validity or runtime behavior and is trivially fixable. - No prior reviews or outstanding comments on the PR.
Given the minimal, pattern-following runtime change and solid test coverage, this is safe to approve; the comment nit can be addressed in a follow-up or before merge without blocking.
|
|
||
| // Surfacing the error also destroys the stream, which emits 'error'. | ||
| const errorEvents = []; |
There was a problem hiding this comment.
🟡 The comment says "Surfacing the error also destroys the stream, which emits 'error'" — but the assertion below is expect(errorEvents).toEqual([]), which asserts the opposite. The assertion is correct (after await finished.promise autoDestroy has already run, so errorOrDestroy short-circuits and no 'error' fires); the comment should be reworded to say the handler is registered so an unexpected 'error' won't crash the test, and that none is expected here.
Extended reasoning...
What the bug is
The comment on line 136 of test/js/node/child_process/child-process-stdio.test.js reads:
// Surfacing the error also destroys the stream, which emits 'error'.
This directly implies that the write-after-end error will cause an 'error' event to be emitted on child.stdin, and that errorEvents will capture it. But sixteen lines later the test asserts:
expect(errorEvents).toEqual([]);The comment and the assertion contradict each other. A reader cannot tell which one reflects the intended behavior.
Which one is correct
The assertion is correct; the comment is wrong. Step-by-step:
child.stdinis aWriteStreamcreated viawritableFromFileSink, which uses the defaultautoClose→autoDestroy: true.child.stdin.end("kept\n")runs. When the writable finishes,finish()insrc/js/internal/streams/writable.tsemits'finish'and then — synchronously in the same tick, becauseautoDestroyis set — callsstream.destroy(), which setskDestroyed.await finished.promiseresumes on the microtask after'finish'fired, so by the time the test continues,_writableStatealready haskDestroyedset.child.stdin.write("dropped\n", cb)now hits the new guard inwriteFast(this.writableEnded || this.destroyed) and delegates toWritable.prototype.write. That path constructsERR_STREAM_WRITE_AFTER_END, schedulesprocess.nextTick(cb, err), and callserrorOrDestroy(stream, err, true).errorOrDestroy(insrc/js/internal/streams/destroy.ts) checksw.destroyedfirst and returns early — so no'error'event is emitted.
Result: the callback receives ERR_STREAM_WRITE_AFTER_END, but errorEvents stays []. This matches Node's behavior for this exact sequence, and the assertion toEqual([]) is empirically right.
Why the comment is misleading
The comment describes the other scenario in this PR — the process-stdio test and the second child.stdin test — where write() is called immediately after end() without awaiting 'finish'. In that case the stream is not yet destroyed, errorOrDestroy proceeds, and both the callback and an 'error' event fire. That is what "Surfacing the error also destroys the stream, which emits 'error'" describes. But this test deliberately awaits 'finish' first, so it exercises the already-destroyed branch instead.
Impact
This is a documentation-only issue in test code. The test is functionally correct and not flaky; the fix in streams.ts is unaffected. The only cost is reader confusion: someone diffing the comment against the [] assertion will assume one of them is a mistake and won't know which.
Suggested fix
Reword the comment to match what the test actually verifies, e.g.:
// Register an 'error' handler so an unexpected emission doesn't crash the test.
// None is expected here: autoDestroy has already run by the time we write, so
// errorOrDestroy short-circuits and only the callback receives the error.
const errorEvents = [];
child.stdin.on("error", err => errorEvents.push(err.code));(or simply drop the comment — the toEqual([]) assertion is self-explanatory once the misleading claim is gone).
|
Superseded by #33508, closing. That PR deletes I verified the supersession rather than assuming it: the four tests from this PR pass unmodified against #33508's implementation, and I have pushed them onto that branch in @claude thanks, the stale comment was right and is fixed in the moved copy: the handler is registered so an unexpected emission fails the assertion rather than crashing the test, and none is expected because autoDestroy has already run by then. If the full |
Repro
keptret:false,cb:ERR_STREAM_WRITE_AFTER_END,error-event:ERR_STREAM_WRITE_AFTER_ENDkept,droppedret:true,cb:nullBun writes the chunk that was submitted after
end(), returnstrue, and calls the write callback with no error. Same shape on a child process's stdin:Cause
writeFast()insrc/js/internal/fs/streams.tsis installed asWriteStream.prototype.writewhenever theFileSinkfast path is enabled. That coversprocess.stdout,process.stderr(viaProcessObjectInternalsandnode:tty) and a child process'sstdin(viawritableFromFileSink).Writable.prototype.writeis where theERR_STREAM_WRITE_AFTER_END/ERR_STREAM_DESTROYEDchecks live.writeFast()replaces it and goes straight to the sink without looking at the stream state, so neither check ran. Forprocess.stdoutthe sink also still had an open fd, so the bytes really were written.Fix
When the stream is already ending or destroyed, hand the write back to
Writable.prototype.write, which drops the chunk and reports the error through the write callback and an'error'event. Everything else keeps the fast path.Verification
test/js/node/child_process/child-process-stdio.test.js:end()returnsfalseand the callback getsERR_STREAM_WRITE_AFTER_END; the bytes never reach the childend()emits'error'onchild.stdindestroy()reportsERR_STREAM_DESTROYEDtest/js/node/process/process-stdio.test.ts:process.stdoutwrite afterend()is dropped, returnsfalse, and reports through both the callback and'error'All four fail on 1.4.0 and pass with this change.
test/js/node/{fs,child_process,process,tty,stream,console}and thetest-file-write-stream*/test-child-process-stdionode harness tests show no new failures.Out of scope: a write to the stdin of a child that has already exited is still accepted silently (node reports
ERR_STREAM_DESTROYED). Nothing tells the JS stream that the subprocess closed the pipe, so that needs separate plumbing. TheBun.spawn/FileSinkside of the same behavior is #32857.