Compare commits

...

1 Commits

Author SHA1 Message Date
Jarred Sumner
62d99ce112 fix(fs): remove unnecessary protectEat() for Buffer paths in PathLike.fromJS
Buffer arguments don't need GC protection in fromJS — sync operations
have the value on the JS stack, and async operations protect via
toThreadSafe(). The extra protect from protectEat() was never balanced,
causing a GC protection leak on every async fs call with a Buffer path.

fix(fs): fix GC protection leak when passing Buffer paths to async fs operations

`PathLike.fromJS` calls `protectEat()` on Buffer arguments, and then
`toThreadSafe()` calls `.protect()` again when the async task takes
ownership. The `ArgumentsSlice` is never deinited on the happy async
path, so the first protect is never balanced — leaking one GC protection
per call. This prevents the Buffer from ever being garbage collected.

Fix by calling `slice.unprotect()` before dispatching async tasks, and
also fix `AsyncReaddirRecursiveTask` and `AsyncCpTask` to call
`deinitAndUnprotect()` instead of `deinit()` during cleanup.
2026-02-20 20:23:21 -08:00
3 changed files with 47 additions and 4 deletions

View File

@@ -694,7 +694,7 @@ pub fn NewAsyncCpTask(comptime is_shell: bool) type {
this.result.err.deinit();
}
if (comptime !is_shell) this.ref.unref(this.evtloop);
this.args.deinit();
this.args.deinitAndUnprotect();
this.promise.deinit();
this.arena.deinit();
bun.destroy(this);
@@ -1249,7 +1249,7 @@ pub const AsyncReaddirRecursiveTask = struct {
}
this.ref.unref(this.globalObject.bunVM());
this.args.deinit();
this.args.deinitAndUnprotect();
bun.default_allocator.free(this.root_path.slice());
this.clearResultList();
this.promise.deinit();
@@ -3050,6 +3050,13 @@ pub const Arguments = struct {
}
}
pub fn deinitAndUnprotect(this: *const Cp) void {
if (this.flags.deinit_paths) {
this.src.deinitAndUnprotect();
this.dest.deinitAndUnprotect();
}
}
pub fn fromJS(ctx: *jsc.JSGlobalObject, arguments: *ArgumentsSlice) bun.JSError!Cp {
const src = try PathLike.fromJS(ctx, arguments) orelse {
return ctx.throwInvalidArguments("src must be a string or TypedArray", .{});

View File

@@ -674,7 +674,7 @@ pub const PathLike = union(enum) {
try Valid.pathBuffer(buffer, ctx);
try Valid.pathNullBytes(buffer.slice(), ctx);
arguments.protectEat();
arguments.eat();
return .{ .buffer = buffer };
},
@@ -683,7 +683,7 @@ pub const PathLike = union(enum) {
try Valid.pathBuffer(buffer, ctx);
try Valid.pathNullBytes(buffer.slice(), ctx);
arguments.protectEat();
arguments.eat();
return .{ .buffer = buffer };
},

View File

@@ -0,0 +1,36 @@
import { heapStats } from "bun:jsc";
import { expect, test } from "bun:test";
import { mkdirSync, writeFileSync } from "fs";
import { readdir } from "fs/promises";
import { tempDir } from "harness";
import { join } from "path";
test("fs.promises.readdir with Buffer path does not leak GC protection", async () => {
using dir = tempDir("readdir-leak", {});
const base = join(String(dir), "a".repeat(200), "b".repeat(200));
mkdirSync(base, { recursive: true });
for (let i = 0; i < 3; i++) {
const sub = join(base, `sub${i}`);
mkdirSync(sub);
for (let j = 0; j < 3; j++) {
writeFileSync(join(sub, `f${j}`), "x");
}
}
// Warm up
for (let i = 0; i < 100; i++) {
await readdir(Buffer.from(base), { recursive: true });
}
Bun.gc(true);
const before = heapStats().protectedObjectCount;
for (let i = 0; i < 1000; i++) {
await readdir(Buffer.from(base), { recursive: true });
}
Bun.gc(true);
const after = heapStats().protectedObjectCount;
// Should not accumulate protected objects — allow a small margin for noise
expect(after - before).toBeLessThan(10);
});