Fix FileSink.start() crash when called without path/fd on an open writer - #30953
Conversation
When calling .start() on a FileSink created by Bun.file(path).writer()
with an options object that contains neither a 'path' nor an 'fd'
property (e.g. .start({}) or .start({ highWaterMark: N })), the options
were converted to Start::FileSink with input_path = Fd::INVALID, which
was then passed to setup() -> open_for_writing -> dup(Fd::INVALID),
tripping a debug assertion in sys::Error::with_fd.
Skip the setup() call when the resulting input_path is the invalid-fd
placeholder so the existing file setup is preserved.
|
Updated 11:19 PM PT - May 17th, 2026
✅ @robobun, your commit 42d8725d6b414f4674f46f45c3089061a2403a23 passed in 🧪 To try this PR locally: bunx bun-pr 30953That installs a local version of the PR into your bun-30953 --bun |
|
No actionable comments were generated in the recent review. 🎉 ℹ️ Recent review info⚙️ Run configurationConfiguration used: Path: .coderabbit.yaml Review profile: ASSERTIVE Plan: Pro Run ID: 📒 Files selected for processing (3)
WalkthroughThis PR adds a guard to ChangesFileSink invalid file descriptor handling
🚥 Pre-merge checks | ✅ 4✅ Passed checks (4 passed)
✏️ Tip: You can configure your own custom pre-merge checks in the settings. Comment |
| crate::webcore::PathOrFileDescriptor::Fd(Fd::INVALID) | ||
| ) => | ||
| { | ||
| // PORT NOTE: `streams::FileSinkOptions` mirrors `file_sink::Options` |
There was a problem hiding this comment.
They should use the same options type? Can you also change that?
There was a problem hiding this comment.
Done in 42d8725 — streams::FileSinkOptions is now a type alias for file_sink::Options, so start() passes the options straight to setup() without the bridge.
There was a problem hiding this comment.
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/bun/util/filesink.test.ts`:
- Line 273: Replace the tmpdirSync() usage when constructing path with the
harness tempDir two-arg overload: instead of const path = join(tmpdirSync(),
"filesink-restart.txt"); call tempDir with two arguments to create an isolated
disposable directory (e.g., tempDir(<test/context identifier>,
"filesink-restart")) and use join(tempDir(...), "filesink-restart.txt"); update
the import/usage to reference tempDir and remove tmpdirSync.
🪄 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: ef800f5c-7845-4d9a-81b2-d64bda15cdf3
📒 Files selected for processing (2)
src/runtime/webcore/FileSink.rstest/js/bun/util/filesink.test.ts
There was a problem hiding this comment.
LGTM — small, well-tested guard to skip setup() when .start() is called without a path/fd, fixing a fuzzer-found debug assertion.
Extended reasoning...
Overview
This PR adds a single match guard in FileSink::start() (src/runtime/webcore/FileSink.rs) so that when the incoming streams::Start::FileSink carries the placeholder PathOrFileDescriptor::Fd(Fd::INVALID) — which Start::from_js_with_tag produces when the JS options object has neither path nor fd (streams.rs:281-284) — the setup() call is skipped and only the done/started/signal tail runs. A regression test in test/js/bun/util/filesink.test.ts exercises .start({}) and .start({ highWaterMark }) on an already-open writer and verifies it still writes correctly.
Security risks
None. No new inputs are accepted, no resources are opened, no privilege boundaries are touched. The change strictly narrows the set of inputs that reach setup()/open_for_writing; the skipped case was previously a guaranteed EBADF (release) or debug-assert panic. There is no fd/path injection surface here.
Level of scrutiny
Low-to-moderate. The diff is 5 lines of logic in one match arm plus a test. The root-cause analysis in the PR description is precise and verifiable against streams.rs and the Zig original (FileSink.zig:439-456, which has the same unguarded setup() call — the Rust port's stricter with_fd debug assertion is what surfaced this). The only behavior change for the non-crashing release path is that .start({}) on an open writer now succeeds (preserving the existing fd/poll) instead of returning EBADF, which is the intended semantics and what the new test asserts.
Other factors
- Found by Fuzzilli; the fix is the minimal guard at the right layer (JS-exposed
.start()is the only path that doesn't override the placeholder —Blob::get_writeralways overwrites it). - The fall-through to
_ => {}is the same arm already taken for non-FileSinkStartvariants, so no new code path is introduced. - Test covers both the crash case and that the writer remains functional afterward.
- No outstanding reviewer comments; bug hunter found nothing.
Replace the duplicate FileSinkOptions struct in streams.rs with a type alias to file_sink::Options so FileSink::start() can pass the options straight through to setup() without field-by-field bridging.
Error.fd defaults to Fd::INVALID and to_system_error() / Display already treat it as 'no fd attached' via fd_unwrap_valid(). Calling with_fd(Fd::INVALID) is therefore a no-op, but the debug_assert! turned it into a hard crash in debug/fuzz builds whenever a syscall wrapper (dup, fcntl, close, read/write, ...) is reached with the invalid-fd sentinel — something user input can drive through several PathOrFileDescriptor paths. #30953 fixed one such path in FileSink::start; Fuzzilli keeps hitting others under REPRL state that doesn't reproduce standalone. Drop the assertion so these cases surface as the proper EBADF/JS error instead of a panic. Release behaviour is unchanged.
The #30953 guard short-circuits these args before setup() so this test is smoke coverage, not the direct with_fd regression guard (that's the Rust unit test in src/sys/Error.rs).
What
Fixes a debug assertion failure (
fd != Fd::INVALIDinsrc/sys/Error.rs) when calling.start()on aFileSinkcreated byBun.file(path).writer()with an options object that does not include apathorfdproperty.Why
Start::from_js_with_tag::<FileSink>returnsStart::FileSink { input_path: Fd(Fd::INVALID), .. }when the options object has neitherpathnorfd.FileSink::start()then unconditionally calledsetup(), which calleddup_with_flags(Fd::INVALID, 0). Thefcntlfails withEBADF, and when building the error via.with_fd(Fd::INVALID)we hit the debug assertion.In
Blob::get_writerthe invalid-fd placeholder is always overwritten with the real file path/fd beforestart()is called, but the JS-exposed.start()has no such override.How
Add a match guard in
FileSink::start()to skipsetup()when the incominginput_pathis the invalid-fd placeholder — the writer is already configured, so we only updatedone/started/signalstate as before. Calls with a realpathorfdstill re-runsetup().Found by Fuzzilli.