-
Notifications
You must be signed in to change notification settings - Fork 5k
Fix crash in Bun.file().writer() with invalid path/fd options
#30277
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -207,6 +207,13 @@ it("write result is not cumulative", async () => { | |
| await util.promisify(fs.close)(fd); | ||
| }); | ||
|
|
||
| it.skipIf(isWindows)("writer() throws on invalid options instead of crashing", () => { | ||
| const file = Bun.file(path.join(tmpdirSync(), "test.txt")); | ||
| expect(() => file.writer({ path: 123 })).toThrow(expect.objectContaining({ code: "EINVAL" })); | ||
| expect(() => file.writer({ fd: "not a number" })).toThrow(expect.objectContaining({ code: "EBADF" })); | ||
| expect(() => file.writer({ fd: -9999.5 })).toThrow(expect.objectContaining({ code: "EBADF" })); | ||
| }); | ||
|
Comment on lines
+210
to
+215
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🛠️ Refactor suggestion | 🟠 Major | ⚡ Quick win Use Line 211 adds another Proposed change it.skipIf(isWindows)("writer() throws on invalid options instead of crashing", () => {
- const file = Bun.file(path.join(tmpdirSync(), "test.txt"));
+ using dir = tempDir("filesink-invalid-options");
+ const file = Bun.file(path.join(dir, "test.txt"));
expect(() => file.writer({ path: 123 })).toThrow(expect.objectContaining({ code: "EINVAL" }));
expect(() => file.writer({ fd: "not a number" })).toThrow(expect.objectContaining({ code: "EBADF" }));
expect(() => file.writer({ fd: -9999.5 })).toThrow(expect.objectContaining({ code: "EBADF" }));
});-import { fileDescriptorLeakChecker, isPosix, isWindows, tmpdirSync } from "harness";
+import { fileDescriptorLeakChecker, isPosix, isWindows, tempDir, tmpdirSync } from "harness";As per coding guidelines: 🤖 Prompt for AI Agents
Collaborator
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. This test matches the existing convention in this file — every other test here uses
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
✏️ Learnings added
🧠 Learnings used |
||
|
|
||
| if (isWindows) { | ||
| it("ENOENT, Windows", () => { | ||
| expect(() => Bun.file("A:\\this-does-not-exist.txt").writer()).toThrow( | ||
|
|
||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
🟡 Nit: the
tryon this line can also early-return (e.g.{ get highWaterMark() { throw 1 } }), in which casesinkfrom line 2954 is leaked — there's noerrdefer sink.deref(). Since this PR is already addingsink.deref()for the.errbranch right below, anerrdefer sink.deref();after line 2954 would cover this path (and the existing manualsink.deref()calls could then be dropped). Pre-existing, but it's the same hardening this PR is doing.Extended reasoning...
What the bug is
FileSink.init(FileSink.zig:553) heap-allocates the sink viabun.newwith a ref count and incrementslive_count; it must bederef()'d to be freed. At Blob.zig:2954 the sink is created, but there is noerrdefer sink.deref()afterward. The very next failure point that usestryis line 2977:fromJSWithTag(streams.zig:74) returnsbun.JSError!Startand internally doestry value.fastGet(globalThis, .highWaterMark),try value.fastGet(globalThis, .path),try value.getTruthy(globalThis, "fd"), andtry path.toSlice(...). Any of these propagateerror.JSErrorif a JS getter throws ortoSlicefails. When that happens,tryreturns fromgetWriterwithout ever callingsink.deref(), leaking the FileSink.Step-by-step proof
Bun.file('/tmp/x').writer({ get highWaterMark() { throw 1 } })sink = FileSink.init(...)heap-allocates a FileSink,live_count+= 1.arguments[0].isObject()is true.fromJSWithTagcallsvalue.fastGet(globalThis, .highWaterMark), which invokes the throwing getter → returnserror.JSError.trypropagateserror.JSErrorout ofgetWriter. Noerrdeferruns forsink. The onlydeferisinput_path.deinit()(line 2968).sinkis never deref'd → leaked.Why existing code doesn't prevent it
The PR adds
sink.deref()for the.errunion variant returned byfromJSWithTag(line 2980), and there's an existingsink.deref()in thesink.starterror branch (line 2995). But neither covers the Zig-error (error.JSError) path on line 2977, which bypasses theswitchentirely viatry.Impact
Small heap leak per call, only triggerable with adversarial throwing getters on the options object. Not a correctness or crash issue. However, this PR is specifically hardening invalid-options handling in this exact block and explicitly adds
sink.deref()one line below — so the omission on the adjacenttrypath is an incomplete fix of the same logical change.Fix
Add
errdefer sink.deref();immediately after line 2954. This covers thetry fromJSWithTagpath and any futuretry-paths in this block. Optionally, the manualsink.deref()calls in the.errbranches (lines 2980, 2995) could then become plainreturns relying on the errdefer, though leaving them as-is is also fine since those branches don't return a Zig error.