Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 5 additions & 3 deletions src/bun.js/webcore/fetch/FetchTasklet.zig
Original file line number Diff line number Diff line change
Expand Up @@ -234,9 +234,11 @@ pub const FetchTasklet = struct {
this.readable_stream_ref.deinit();

this.scheduled_response_buffer.deinit();
if (this.request_body != .ReadableStream or this.is_waiting_request_stream_start) {
this.request_body.detach();
}
// Always detach request_body regardless of type.
// When request_body is a ReadableStream, startRequestStream() creates
// an independent Strong reference in ResumableSink, so FetchTasklet's
// reference becomes redundant and must be released to avoid leaks.
this.request_body.detach();

this.abort_reason.deinit();
this.check_server_identity.deinit();
Expand Down
265 changes: 265 additions & 0 deletions test/js/web/fetch/fetch-cyclic-reference.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,265 @@
import { heapStats } from "bun:jsc";
import { afterAll, describe, expect, test } from "bun:test";

describe("FetchTasklet cyclic reference", () => {
let server: ReturnType<typeof Bun.serve> | null = null;

afterAll(() => {

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is not necessary

server?.stop(true);
});

test("response stream should not leak when response has cyclic reference", async () => {
server = Bun.serve({

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
server = Bun.serve({
await using server = Bun.serve({

port: 0,
fetch(req) {
return new Response("hello world");
},
});

const url = `http://localhost:${server.port}/`;

async function leak() {
const response = await fetch(url);
const text = await response.text();

// Create cyclic reference: response -> body stream -> response
// @ts-ignore
response.selfRef = response;

return text;
}

for (let i = 0; i < 1000; i++) {
await leak();
}

await Bun.sleep(10);
Bun.gc(true);
await Bun.sleep(10);
Bun.gc(true);

const responseCount = heapStats().objectTypeCounts.Response || 0;

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It causes the Response to be leaked? Not the ReadableStream?

expect(responseCount).toBeLessThanOrEqual(100);
});

test("response stream should not leak when streaming response body with cyclic reference", async () => {
server?.stop(true);
server = Bun.serve({
port: 0,
fetch(req) {
const stream = new ReadableStream({
start(controller) {
controller.enqueue(new TextEncoder().encode("streaming "));
controller.enqueue(new TextEncoder().encode("response "));
controller.enqueue(new TextEncoder().encode("body"));
controller.close();
},
});
return new Response(stream);
},
});

const url = `http://localhost:${server.port}/`;

async function leak() {
const response = await fetch(url);

// Create cyclic reference before consuming body
// @ts-ignore
response.selfRef = response;

// Get the body as a stream
const reader = response.body!.getReader();
const chunks: Uint8Array[] = [];

while (true) {
const { done, value } = await reader.read();
if (done) break;
chunks.push(value);
}

return new TextDecoder().decode(Buffer.concat(chunks));
}

for (let i = 0; i < 500; i++) {
await leak();
}

await Bun.sleep(10);
Bun.gc(true);
await Bun.sleep(10);
Bun.gc(true);

const responseCount = heapStats().objectTypeCounts.Response || 0;
const readableStreamCount = heapStats().objectTypeCounts.ReadableStream || 0;
expect(responseCount).toBeLessThanOrEqual(100);
expect(readableStreamCount).toBeLessThanOrEqual(100);
});

test("response should not leak when body stream references response", async () => {
server?.stop(true);
server = Bun.serve({
port: 0,
fetch(req) {
return new Response("test body content");
},
});

const url = `http://localhost:${server.port}/`;

async function leak() {
const response = await fetch(url);
const body = response.body;

// Create cyclic reference: body stream -> response -> body stream
// @ts-ignore
if (body) body.response = response;
// @ts-ignore
response.bodyStream = body;

await response.text();
}

for (let i = 0; i < 1000; i++) {
await leak();
}

await Bun.sleep(10);
Bun.gc(true);
await Bun.sleep(10);
Bun.gc(true);

const responseCount = heapStats().objectTypeCounts.Response || 0;
expect(responseCount).toBeLessThanOrEqual(100);
});

test("fetch with request body stream should not leak with cyclic reference", async () => {
server?.stop(true);
server = Bun.serve({
port: 0,
async fetch(req) {
const body = await req.text();
return new Response(`received: ${body}`);
},
});

const url = `http://localhost:${server.port}/`;

async function leak() {
const requestBody = new ReadableStream({
start(controller) {
controller.enqueue(new TextEncoder().encode("request body"));
controller.close();
},
});

const request = new Request(url, {
method: "POST",
body: requestBody,
});

// Create cyclic reference
// @ts-ignore
requestBody.request = request;
// @ts-ignore
request.bodyStream = requestBody;

const response = await fetch(request);
return await response.text();
}

for (let i = 0; i < 500; i++) {
await leak();
}

await Bun.sleep(10);
Bun.gc(true);
await Bun.sleep(10);
Bun.gc(true);

const requestCount = heapStats().objectTypeCounts.Request || 0;
const readableStreamCount = heapStats().objectTypeCounts.ReadableStream || 0;
expect(requestCount).toBeLessThanOrEqual(100);
expect(readableStreamCount).toBeLessThanOrEqual(100);
Comment on lines +39 to +51

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🧹 Nitpick | 🔵 Trivial

Consider extracting magic numbers to named constants.

The test uses several magic numbers without explanation:

  • 500 iterations
  • 100 object threshold
  • 10ms sleep duration

Extract these to named constants at the top of the test suite to improve maintainability and document the rationale. For example, MAX_LEAKED_OBJECTS = 100 with a comment explaining why 100 is acceptable after 500 iterations.

🤖 Prompt for AI Agents
In @test/js/web/fetch/fetch-cyclic-reference.test.ts around lines 39 - 51,
Extract the magic numbers used in the test into named constants at the top of
the test file (e.g., const LEAK_ITERATIONS = 500, const MAX_LEAKED_OBJECTS =
100, const SLEEP_MS = 10) with a short comment explaining the rationale (for
example, "MAX_LEAKED_OBJECTS = 100 is acceptable after LEAK_ITERATIONS
iterations"). Replace the literals in the loop (for (let i = 0; i < 500; i++)),
the Bun.sleep calls (Bun.sleep(10)), and the assertions
(toBeLessThanOrEqual(100)) with the new constants; keep references to leak(),
Bun.gc(true), and heapStats().objectTypeCounts.Request/ReadableStream unchanged.

});

test("fetch with ReadableStream body should not leak streams", async () => {
server?.stop(true);
server = Bun.serve({
port: 0,
async fetch(req) {
const body = await req.text();
return new Response(`received: ${body}`);
},
});

const url = `http://localhost:${server.port}/`;

async function leak() {
const requestBody = new ReadableStream({
start(controller) {
controller.enqueue(new TextEncoder().encode("request body"));
controller.close();
},
});

// Use ReadableStream directly with fetch, no Request object, no cyclic reference
const response = await fetch(url, {
method: "POST",
body: requestBody,
});
return await response.text();
}

for (let i = 0; i < 500; i++) {
await leak();
}

await Bun.sleep(10);
Bun.gc(true);
await Bun.sleep(10);
Bun.gc(true);

const readableStreamCount = heapStats().objectTypeCounts.ReadableStream || 0;
// This currently fails with ~502 streams leaked
expect(readableStreamCount).toBeLessThanOrEqual(100);
Comment thread
coderabbitai[bot] marked this conversation as resolved.
});

test("multiple concurrent fetches should not leak with cyclic references", async () => {
server?.stop(true);
server = Bun.serve({
port: 0,
fetch(req) {
return new Response("concurrent test");
},
});

const url = `http://localhost:${server.port}/`;

async function leak() {
const responses = await Promise.all([fetch(url), fetch(url), fetch(url)]);

// Create cyclic references between responses
// @ts-ignore
responses[0].next = responses[1];
// @ts-ignore
responses[1].next = responses[2];
// @ts-ignore
responses[2].next = responses[0];

await Promise.all(responses.map(r => r.text()));
}

for (let i = 0; i < 300; i++) {
await leak();
}

await Bun.sleep(10);
Bun.gc(true);
await Bun.sleep(10);
Bun.gc(true);

const responseCount = heapStats().objectTypeCounts.Response || 0;
expect(responseCount).toBeLessThanOrEqual(100);
});
});
Comment on lines +4 to +94

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🧹 Nitpick | 🔵 Trivial

Consider extracting common test patterns into helper functions.

Both test cases follow an identical pattern:

  1. Set up server with await using
  2. Define a leak() function
  3. Run 500 iterations
  4. Execute double GC sequence (sleep → gc → sleep → gc)
  5. Assert heap counts

Consider extracting the GC sequence and iteration logic into helper functions to reduce duplication and improve maintainability.

Example refactor
async function forceGarbageCollection() {
  await Bun.sleep(10);
  Bun.gc(true);
  await Bun.sleep(10);
  Bun.gc(true);
}

async function runLeakTest(leakFn: () => Promise<any>, iterations: number) {
  for (let i = 0; i < iterations; i++) {
    await leakFn();
  }
  await forceGarbageCollection();
}
🤖 Prompt for AI Agents
In @test/js/web/fetch/fetch-cyclic-reference.test.ts around lines 4 - 94, Tests
duplicate the iteration + double-GC pattern; extract that logic into helpers to
reduce duplication. Add a helper function (e.g., forceGarbageCollection) that
performs the await Bun.sleep(10); Bun.gc(true); await Bun.sleep(10);
Bun.gc(true) sequence and another helper (e.g., runLeakTest) that accepts the
leak function and iterations, runs the loop calling leak(), then calls
forceGarbageCollection; update both test cases ("fetch with request body stream
should not leak with cyclic reference" and "fetch with ReadableStream body
should not leak streams") to call runLeakTest(leak, 500) and keep their server
setup (Bun.serve), leak() definitions, and final heapStats assertions unchanged.