Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
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
7 changes: 5 additions & 2 deletions src/http/AsyncHTTP.rs
Original file line number Diff line number Diff line change
Expand Up @@ -780,6 +780,7 @@ impl<'a> AsyncHTTP<'a> {
}
let elapsed = (*this).elapsed;
bun_core::scoped_log!(AsyncHTTP, "onAsyncHTTPCallback: {:?}", elapsed);
let slot_released_on_pause = (*this).client.flags.released_active_slot;
callback.run(async_http, result);

// SAFETY: `async_http` is the `async_http` field of a
Expand Down Expand Up @@ -807,8 +808,10 @@ impl<'a> AsyncHTTP<'a> {
std::alloc::Layout::new::<ThreadlocalAsyncHTTP>(),
);

let active_requests = ACTIVE_REQUESTS_COUNT.fetch_sub(1, Ordering::Relaxed);
debug_assert!(active_requests > 0);
if !slot_released_on_pause {
let active_requests = ACTIVE_REQUESTS_COUNT.fetch_sub(1, Ordering::Relaxed);
debug_assert!(active_requests > 0);
}
}
}

Expand Down
29 changes: 29 additions & 0 deletions src/http/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -198,6 +198,12 @@ pub struct Flags {
/// once on a fresh connection but never loops.
pub h3_retried: bool,
pub is_node_http_client: bool,
/// Set by `maybe_pause_receive` when it decrements `ACTIVE_REQUESTS_COUNT`
/// for a backpressure pause (JS has not pulled the buffered body yet);
/// cleared by `resume_receive` when it re-increments. Checked in
/// `on_async_http_callback_raw`'s terminal branch so a request that ends
/// while still paused (abort/close/GC) skips the second decrement.
pub released_active_slot: bool,
}

impl Default for Flags {
Expand All @@ -221,6 +227,7 @@ impl Default for Flags {
force_http3: false,
h3_retried: false,
is_node_http_client: false,
released_active_slot: false,
}
}
}
Expand Down Expand Up @@ -4116,6 +4123,20 @@ impl<'a> HTTPClient<'a> {
self.state.flags.receive_paused = true;
socket.set_timeout(0);
let _ = socket.pause_stream();
// A socket parked for JS-side backpressure is no longer doing I/O, so it
// must not count against `MAX_SIMULTANEOUS_REQUESTS`: otherwise `max`
// retained-but-unread Responses permanently starve every later fetch
// (any origin) until GC finalizes them. `resume_receive` re-acquires
// the slot; the terminal callback checks `released_active_slot` so a
// request that ends while still paused is not double-decremented. Runs
// on the HTTP thread inside `tick()`, so the next loop iteration's
// `drain_events` observes the freed slot without an explicit wakeup.
if !self.flags.released_active_slot {
self.flags.released_active_slot = true;
let prev = crate::async_http::ACTIVE_REQUESTS_COUNT
.fetch_sub(1, core::sync::atomic::Ordering::Relaxed);
debug_assert!(prev > 0);
}
bun_core::scoped_log!(fetch, "pause receive {}", self.async_http_id);
}

Expand All @@ -4124,6 +4145,14 @@ impl<'a> HTTPClient<'a> {
return;
}
self.state.flags.receive_paused = false;
// Re-acquire the slot released in `maybe_pause_receive`. Admission is
// one-way (the request is already in flight), so this may briefly push
// the count above `max`; `drain_events` only gates NEW requests on it.
if self.flags.released_active_slot {
self.flags.released_active_slot = false;
crate::async_http::ACTIVE_REQUESTS_COUNT
.fetch_add(1, core::sync::atomic::Ordering::Relaxed);
}
if socket.is_closed() {
return;
}
Expand Down
74 changes: 74 additions & 0 deletions test/js/web/fetch/fetch-backpressure.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -379,3 +379,77 @@ describe.concurrent("fetch() receive backpressure — streaming consumer shapes"
}
});
});

// Runs in a child so BUN_CONFIG_MAX_HTTP_REQUESTS can be capped without
// affecting the rest of the suite. Before the fix the retain loop wedged at
// the cap and both probes reported HUNG, even the one to a fresh origin.
test("retained unread Responses do not starve later fetch() requests", async () => {
const fixture = /* js */ `
import { createServer } from "node:http";
import { once } from "node:events";

const body = Buffer.alloc(512 * 1024, 97);
async function mk() {
const s = createServer((_, w) => { w.setHeader("content-length", body.length); w.end(body); });
s.listen(0, "127.0.0.1");
await once(s, "listening");
return s;
}
const s1 = await mk(), s2 = await mk();
const O1 = "http://127.0.0.1:" + s1.address().port;
const O2 = "http://127.0.0.1:" + s2.address().port;

const race = (p, ms) => Promise.race([
p.then(v => ({ ok: true, v })),
new Promise(r => setTimeout(() => r({ ok: false }), ms)),
]);

const hold = [];
let wedgedAt = 0;
for (let i = 1; i <= 12; i++) {
const r = await race(fetch(O1 + "/x" + i), 3000);
if (!r.ok) { wedgedAt = i; break; }
hold.push(r.v);
}

const same = await race(fetch(O1 + "/same").then(r => r.arrayBuffer()), 3000);
const second = await race(fetch(O2 + "/second").then(r => r.arrayBuffer()), 3000);

// Draining a retained Response must re-acquire the slot and let the
// body finish so the counter stays balanced.
const drained = hold[0] ? (await hold[0].arrayBuffer()).byteLength : 0;

process.stdout.write(JSON.stringify({
wedgedAt,
retained: hold.length,
same: same.ok,
second: second.ok,
drained,
}));

hold.length = 0;
s1.closeAllConnections(); s1.close();
s2.closeAllConnections(); s2.close();
`;

await using proc = Bun.spawn({
cmd: [bunExe(), "-e", fixture],
env: { ...bunEnv, BUN_CONFIG_MAX_HTTP_REQUESTS: "4" },
stdout: "pipe",
stderr: "pipe",
});
const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]);
const filteredStderr = stderr
.split("\n")
.filter(l => l && !l.startsWith("WARNING: ASAN interferes"))
.join("\n");
expect(filteredStderr).toBe("");
expect(JSON.parse(stdout)).toEqual({
wedgedAt: 0,
retained: 12,
same: true,
second: true,
drained: 512 * 1024,
});
expect(exitCode).toBe(0);
}, 30_000);
Loading