-
Notifications
You must be signed in to change notification settings - Fork 5k
Fix crash in Blob.writer() when options contain invalid path or fd #30253
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 | ||||||||||||||||||||||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
|
|
@@ -268,3 +268,13 @@ | |||||||||||||||||||||||||||||||||
| // more than that indicates a native leak. | ||||||||||||||||||||||||||||||||||
| expect(fileSinkInternals.liveCount()).toBeLessThanOrEqual(baseline + 1); | ||||||||||||||||||||||||||||||||||
| }); | ||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||
| it("writer() throws instead of crashing when options has a non-string path", () => { | ||||||||||||||||||||||||||||||||||
| const file = Bun.file(join(tmpdirSync(), "writer-invalid-path.txt")); | ||||||||||||||||||||||||||||||||||
| expect(() => file.writer({ path: Int32Array } as any)).toThrow(); | ||||||||||||||||||||||||||||||||||
| }); | ||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||
| it("writer() throws instead of crashing when options has an invalid fd", () => { | ||||||||||||||||||||||||||||||||||
| const file = Bun.file(join(tmpdirSync(), "writer-invalid-fd.txt")); | ||||||||||||||||||||||||||||||||||
| expect(() => file.writer({ fd: "not-a-number" } as any)).toThrow(); | ||||||||||||||||||||||||||||||||||
|
Comment on lines
+273
to
+279
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 273 and Line 278 add new Proposed change-import { fileDescriptorLeakChecker, isPosix, isWindows, tmpdirSync } from "harness";
+import { fileDescriptorLeakChecker, isPosix, isWindows, tempDir, tmpdirSync } from "harness";
it("writer() throws instead of crashing when options has a non-string path", () => {
- const file = Bun.file(join(tmpdirSync(), "writer-invalid-path.txt"));
+ using dir = tempDir("filesink-writer-invalid-path");
+ const file = Bun.file(join(dir, "writer-invalid-path.txt"));
expect(() => file.writer({ path: Int32Array } as any)).toThrow();
});
it("writer() throws instead of crashing when options has an invalid fd", () => {
- const file = Bun.file(join(tmpdirSync(), "writer-invalid-fd.txt"));
+ using dir = tempDir("filesink-writer-invalid-fd");
+ const file = Bun.file(join(dir, "writer-invalid-fd.txt"));
expect(() => file.writer({ fd: "not-a-number" } as any)).toThrow();
});As per coding guidelines, 🤖 Prompt for AI Agents
Comment on lines
+274
to
+279
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. 🧹 Nitpick | 🔵 Trivial | ⚡ Quick win Make the throw assertions specific to the expected errno. Line 274 and Line 279 currently accept any thrown error. Assert Proposed assertion strengthening- expect(() => file.writer({ path: Int32Array } as any)).toThrow();
+ expect(() => file.writer({ path: Int32Array } as any)).toThrow(
+ expect.objectContaining({ code: "EINVAL" }),
+ );
- expect(() => file.writer({ fd: "not-a-number" } as any)).toThrow();
+ expect(() => file.writer({ fd: "not-a-number" } as any)).toThrow(
+ expect.objectContaining({ code: "EBADF" }),
+ );📝 Committable suggestion
Suggested change
🤖 Prompt for AI Agents |
||||||||||||||||||||||||||||||||||
| }); | ||||||||||||||||||||||||||||||||||
|
Check failure on line 280 in test/js/bun/util/filesink.test.ts
|
||||||||||||||||||||||||||||||||||
|
Comment on lines
+272
to
+280
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. 🔴 These two new tests will fail on Windows CI: Extended reasoning...What the bug isThe two new regression tests assert that Code path that triggers itIn if (Environment.isWindows) {
const pathlike = store.data.file.pathlike; // <-- the BLOB's own path/fd
...
const fd = ... bun.sys.open(pathlike.path.sliceZ(&file_path), O_WRONLY|O_CREAT|O_NONBLOCK, ...) ...;
var sink = jsc.WebCore.FileSink.init(fd, ...);
...
return sink.toJS(globalThis); // line 2951 — early return
}
// POSIX-only from here on
...
if (arguments.len > 0 and arguments.ptr[0].isObject()) {
stream_start = try jsc.WebCore.streams.Start.fromJSWithTag(...);
switch (stream_start) { ... .err => ... } // <-- the new fix
}The Windows branch reads only Why existing code doesn't prevent itThere is no shared options-parsing step before the platform split — the Windows path was written as a self-contained implementation that returns before the POSIX options handling. Nothing in the Windows block inspects Step-by-step proof on Windows
ImpactBoth new tests will fail on Windows CI, blocking the PR (or breaking How to fixMinimal fix: gate the two new tests with it.skipIf(isWindows)("writer() throws instead of crashing when options has a non-string path", () => { ... });
it.skipIf(isWindows)("writer() throws instead of crashing when options has an invalid fd", () => { ... });A fuller fix would be to validate |
||||||||||||||||||||||||||||||||||
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.
🟡 Pre-existing, but while you're here: the
tryonfromJSWithTagat line 2977 can also propagate aJSError(e.g. a throwing getter forhighWaterMark/path/fdon the options object), in which case thesinkallocated at line 2954 still leaks. Anerrdefer sink.deref();right after line 2954 would cover both this and the.errcase you added, in one line.Extended reasoning...
What the bug is
getWriterallocates a ref-countedFileSinkat Blob.zig:2954 viajsc.WebCore.FileSink.init(...)(which heap-allocates withbun.newand starts at refcount 1). There is noerrdefer sink.deref()between that allocation and thetryat line 2977. IffromJSWithTagreturnserror.JSError, thetrypropagates it straight out ofgetWriterand the sink is never released.The PR correctly adds
sink.deref()for the case wherefromJSWithTagreturns the.errunion variant ofStart(line 2984), butfromJSWithTaghas a second failure mode — a Zigbun.JSErrorin the error-union — which is propagated bytryrather than landing in theswitch.Code path that triggers it
Start.fromJSWithTagfor.FileSink(streams.zig:117–150) does:try value.fastGet(globalThis, .highWaterMark)(line ~120)try value.fastGet(globalThis, .path)(line ~125)try value.getTruthy(globalThis, "fd")try path.toSlice(...)Each of these executes a JS property access on the user-supplied options object. If the options object is a Proxy or has an accessor that throws, the getter throws,
fastGet/getTruthyreturnserror.JSError, and thetryinfromJSWithTagpropagates it. Back ingetWriter, the outertryat line 2977 propagates again, returning beforesinkis ever deref'd.Step-by-step proof
sink = FileSink.init(...)→ heap object, refcount = 1,live_countincremented.input_pathis computed;defer input_path.deinit()registered (so the path itself does not leak).arguments.len > 0and the argument is an object → enter the branch.fromJSWithTagrunsvalue.fastGet(globalThis, .highWaterMark), which invokes the getter, which throws →error.JSErrorpropagates out offromJSWithTag.tryat 2977 propagateserror.JSErrorout ofgetWriter. Theswitch(lines 2978–2990) is never reached, so the newsink.deref()at 2984 doesn't run.defer input_path.deinit()from step 2 runs.sinkis leaked.Why existing code doesn't prevent it
There is no
errdefer sink.deref()anywhere in this function. All other error paths (lines 2944, 2984, 2995) manually callsink.deref()before returning, but thetryat 2977 short-circuits past all of them.Impact
A native
FileSinkallocation (plus its associated writer state) is leaked for the lifetime of the process each time this is hit. The trigger is adversarial — a throwing getter or Proxy trap on the options object — so it's unlikely in normal code, but it is the exact same class of bug ("sink not cleaned up on error in this block") that this PR sets out to fix, in the very same lines being edited.Fix
Add immediately after line 2954:
This covers the
tryJSError path. With theerrdeferin place, the explicitsink.deref()calls at lines 2984 and 2995 become redundant and can be removed (they're followed by error returns), simplifying the function. Note thesink.deref()at line 2944 is in a separate early-return block before line 2954 and would remain.