Compare commits

..

3 Commits

Author SHA1 Message Date
Claude Bot
e5d06d48b1 fix(bmalloc): add patch verification, unistd.h include, named constants
Address review feedback:
- Add string(FIND) verification after each patch to warn if the
  expected pattern was not found (silent failure detection for
  WebKit version upgrades)
- Add #include <unistd.h> to pas_utils.h patch (guarded by
  !PAS_OS(WINDOWS)) so usleep() compiles
- Use named constants PAS_SYSCALL_MAX_RETRIES and
  PAS_SYSCALL_RETRY_DELAY_US in the PAS_SYSCALL macro for
  consistency with the BSyscall.h style

Co-Authored-By: Claude <noreply@anthropic.com>
2026-02-27 01:16:37 +00:00
autofix-ci[bot]
eacba7987f [autofix.ci] apply automated fixes 2026-02-27 01:04:27 +00:00
Claude Bot
1fc4dd4f83 fix(bmalloc): add backoff to SYSCALL macro and remove MADV_DONTDUMP
The SYSCALL and PAS_SYSCALL macros in bmalloc retry syscalls returning
EAGAIN in a zero-delay tight loop. When madvise(MADV_DONTDUMP) returns
EAGAIN under kernel mmap_write_lock contention (concurrent GC threads),
this causes 250K+ retries/sec/thread and 100% CPU, freezing the process.

Fix applied via patches to downloaded WebKit headers in SetupWebKit.cmake:

1. Add usleep(1000) backoff and 100-retry cap to SYSCALL/PAS_SYSCALL
   macros. When the syscall succeeds on first try (common case), zero
   overhead. On EAGAIN, retries up to 100 times with 1ms delay (~100ms
   max), then gives up gracefully.

2. Remove MADV_DONTDUMP/MADV_DODUMP calls on Linux. These require the
   kernel's exclusive mmap_write_lock (unlike MADV_DONTNEED which only
   needs a read lock) and are the primary contention source. They only
   affect core dump size, not allocation correctness.

Closes #27490

Co-Authored-By: Claude <noreply@anthropic.com>
2026-02-27 01:02:28 +00:00
5 changed files with 184 additions and 183 deletions

View File

@@ -260,3 +260,116 @@ file(RENAME ${CACHE_PATH}/bun-webkit ${WEBKIT_PATH})
if(APPLE)
file(REMOVE_RECURSE ${WEBKIT_INCLUDE_PATH}/unicode)
endif()
# --- Apply bmalloc patches ---
# Fix: SYSCALL/PAS_SYSCALL macros spin at 100% CPU on madvise EAGAIN (oven-sh/bun#27490)
#
# The SYSCALL macro retries syscalls returning EAGAIN in a zero-delay tight loop.
# Under kernel mmap_write_lock contention (e.g. concurrent GC threads calling
# madvise(MADV_DONTDUMP)), this causes 250K+ retries/sec/thread and 100% CPU.
#
# Fix has two parts:
# 1. Add usleep(1000) backoff and 100-retry cap to SYSCALL/PAS_SYSCALL macros
# 2. Remove MADV_DONTDUMP/MADV_DODUMP calls which require mmap_write_lock
# (MADV_DONTDUMP only affects core dump size, not allocation correctness)
set(BMALLOC_INCLUDE ${WEBKIT_INCLUDE_PATH}/bmalloc)
# Patch BSyscall.h: add backoff and retry cap to SYSCALL macro
set(BSYSCALL_H ${BMALLOC_INCLUDE}/BSyscall.h)
if(EXISTS ${BSYSCALL_H})
file(READ ${BSYSCALL_H} BSYSCALL_CONTENT)
string(REPLACE
"#include <errno.h>
#define SYSCALL(x) do { \\
while ((x) == -1 && errno == EAGAIN) { } \\
} while (0);"
"#include <errno.h>
#include <unistd.h>
#define BSYSCALL_MAX_RETRIES 100
#define BSYSCALL_RETRY_DELAY_US 1000
#define SYSCALL(x) do { \\
int _syscall_tries = 0; \\
while ((x) == -1 && errno == EAGAIN) { \\
if (++_syscall_tries > BSYSCALL_MAX_RETRIES) break; \\
usleep(BSYSCALL_RETRY_DELAY_US); \\
} \\
} while (0);"
BSYSCALL_CONTENT "${BSYSCALL_CONTENT}")
string(FIND "${BSYSCALL_CONTENT}" "BSYSCALL_MAX_RETRIES" BSYSCALL_PATCH_APPLIED)
if(BSYSCALL_PATCH_APPLIED EQUAL -1)
message(WARNING "BSyscall.h patch did not apply - header may have changed in new WebKit version")
else()
message(STATUS "Patched BSyscall.h: SYSCALL macro backoff")
endif()
file(WRITE ${BSYSCALL_H} "${BSYSCALL_CONTENT}")
endif()
# Patch pas_utils.h: add backoff and retry cap to PAS_SYSCALL macro
# Also add #include <unistd.h> for usleep()
set(PAS_UTILS_H ${BMALLOC_INCLUDE}/pas_utils.h)
if(EXISTS ${PAS_UTILS_H})
file(READ ${PAS_UTILS_H} PAS_UTILS_CONTENT)
string(REPLACE
"#include <string.h>"
"#include <string.h>
#if !PAS_OS(WINDOWS)
#include <unistd.h>
#endif"
PAS_UTILS_CONTENT "${PAS_UTILS_CONTENT}")
string(REPLACE
"#define PAS_SYSCALL(x) do { \\
while ((x) == -1 && errno == EAGAIN) { } \\
} while (0)"
"#define PAS_SYSCALL_MAX_RETRIES 100
#define PAS_SYSCALL_RETRY_DELAY_US 1000
#define PAS_SYSCALL(x) do { \\
int _pas_syscall_tries = 0; \\
while ((x) == -1 && errno == EAGAIN) { \\
if (++_pas_syscall_tries > PAS_SYSCALL_MAX_RETRIES) break; \\
usleep(PAS_SYSCALL_RETRY_DELAY_US); \\
} \\
} while (0)"
PAS_UTILS_CONTENT "${PAS_UTILS_CONTENT}")
string(FIND "${PAS_UTILS_CONTENT}" "PAS_SYSCALL_MAX_RETRIES" PAS_PATCH_APPLIED)
if(PAS_PATCH_APPLIED EQUAL -1)
message(WARNING "pas_utils.h patch did not apply - header may have changed in new WebKit version")
else()
message(STATUS "Patched pas_utils.h: PAS_SYSCALL macro backoff")
endif()
file(WRITE ${PAS_UTILS_H} "${PAS_UTILS_CONTENT}")
endif()
# Patch VMAllocate.h: remove MADV_DONTDUMP/MADV_DODUMP (Linux only)
# These require mmap_write_lock and are the primary contention source.
# MADV_DONTDUMP only affects core dump size, not allocation correctness.
set(VMALLOCATE_H ${BMALLOC_INCLUDE}/VMAllocate.h)
if(EXISTS ${VMALLOCATE_H})
file(READ ${VMALLOCATE_H} VMALLOCATE_CONTENT)
string(FIND "${VMALLOCATE_CONTENT}" "MADV_DONTDUMP" VMALLOCATE_HAS_DONTDUMP)
string(REPLACE
" SYSCALL(madvise(p, vmSize, MADV_DONTNEED));
#if BOS(LINUX)
SYSCALL(madvise(p, vmSize, MADV_DONTDUMP));
#endif"
" SYSCALL(madvise(p, vmSize, MADV_DONTNEED));"
VMALLOCATE_CONTENT "${VMALLOCATE_CONTENT}")
string(REPLACE
" SYSCALL(madvise(p, vmSize, MADV_NORMAL));
#if BOS(LINUX)
SYSCALL(madvise(p, vmSize, MADV_DODUMP));
#endif"
" SYSCALL(madvise(p, vmSize, MADV_NORMAL));"
VMALLOCATE_CONTENT "${VMALLOCATE_CONTENT}")
string(FIND "${VMALLOCATE_CONTENT}" "MADV_DONTDUMP" VMALLOCATE_STILL_HAS_DONTDUMP)
if(NOT VMALLOCATE_HAS_DONTDUMP EQUAL -1 AND NOT VMALLOCATE_STILL_HAS_DONTDUMP EQUAL -1)
message(WARNING "VMAllocate.h patch did not apply - header may have changed in new WebKit version")
else()
message(STATUS "Patched VMAllocate.h: removed MADV_DONTDUMP/MADV_DODUMP")
endif()
file(WRITE ${VMALLOCATE_H} "${VMALLOCATE_CONTENT}")
endif()

View File

@@ -228,16 +228,16 @@ To build for macOS x64:
The order of the `--target` flag does not matter, as long as they're delimited by a `-`.
| --target | Operating System | Architecture | Modern | Baseline | Libc |
| --------------------- | ---------------- | ------------ | ------ | -------- | ----- |
| bun-linux-x64 | Linux | x64 | ✅ | ✅ | glibc |
| bun-linux-arm64 | Linux | arm64 | ✅ | N/A | glibc |
| bun-windows-x64 | Windows | x64 | ✅ | ✅ | - |
| bun-windows-arm64 | Windows | arm64 | ✅ | N/A | - |
| bun-darwin-x64 | macOS | x64 | ✅ | ✅ | - |
| bun-darwin-arm64 | macOS | arm64 | ✅ | N/A | - |
| bun-linux-x64-musl | Linux | x64 | ✅ | ✅ | musl |
| bun-linux-arm64-musl | Linux | arm64 | ✅ | N/A | musl |
| --target | Operating System | Architecture | Modern | Baseline | Libc |
| -------------------- | ---------------- | ------------ | ------ | -------- | ----- |
| bun-linux-x64 | Linux | x64 | ✅ | ✅ | glibc |
| bun-linux-arm64 | Linux | arm64 | ✅ | N/A | glibc |
| bun-windows-x64 | Windows | x64 | ✅ | ✅ | - |
| bun-windows-arm64 | Windows | arm64 | ✅ | N/A | - |
| bun-darwin-x64 | macOS | x64 | ✅ | ✅ | - |
| bun-darwin-arm64 | macOS | arm64 | ✅ | N/A | - |
| bun-linux-x64-musl | Linux | x64 | ✅ | ✅ | musl |
| bun-linux-arm64-musl | Linux | arm64 | ✅ | N/A | musl |
<Warning>
On x64 platforms, Bun uses SIMD optimizations which require a modern CPU supporting AVX2 instructions. The `-baseline`

View File

@@ -54,10 +54,6 @@ pub fn NewHTTPUpgradeClient(comptime ssl: bool) type {
// to the shared SSL context from C++.
custom_ssl_ctx: ?*uws.SocketContext = null,
// Expected Sec-WebSocket-Accept value for handshake validation per RFC 6455 §4.2.2.
// This is base64(SHA-1(Sec-WebSocket-Key + "258EAFA5-E914-47DA-95CA-C5AB0DC85B11")).
expected_accept: [28]u8 = .{0} ** 28,
const State = enum {
initializing,
reading,
@@ -137,7 +133,7 @@ pub fn NewHTTPUpgradeClient(comptime ssl: bool) type {
}
}
const request_result = buildRequestBody(
const body = buildRequestBody(
vm,
pathname,
ssl,
@@ -147,7 +143,6 @@ pub fn NewHTTPUpgradeClient(comptime ssl: bool) type {
extra_headers,
if (target_authorization) |auth| auth.slice() else null,
) catch return null;
const body = request_result.body;
// Build proxy state if using proxy
// The CONNECT request is built using local variables for proxy_authorization and proxy_headers
@@ -214,7 +209,6 @@ pub fn NewHTTPUpgradeClient(comptime ssl: bool) type {
.input_body_buf = if (using_proxy) connect_request else body,
.state = .initializing,
.proxy = proxy_state,
.expected_accept = request_result.expected_accept,
.subprotocols = brk: {
var subprotocols = bun.StringSet.init(bun.default_allocator);
var it = bun.http.HeaderValueIterator.init(protocol_for_subprotocols.slice());
@@ -929,10 +923,7 @@ pub fn NewHTTPUpgradeClient(comptime ssl: bool) type {
return;
}
if (!std.mem.eql(u8, websocket_accept_header.value, &this.expected_accept)) {
this.terminate(ErrorCode.mismatch_websocket_accept_header);
return;
}
// TODO: check websocket_accept_header.value
const overflow_len = remain_buf.len;
var overflow: []u8 = &.{};
@@ -1174,11 +1165,6 @@ fn buildConnectRequest(
return buf.toOwnedSlice();
}
const BuildRequestResult = struct {
body: []u8,
expected_accept: [28]u8,
};
fn buildRequestBody(
vm: *jsc.VirtualMachine,
pathname: *const jsc.ZigString,
@@ -1188,7 +1174,7 @@ fn buildRequestBody(
client_protocol: *const jsc.ZigString,
extra_headers: NonUTF8Headers,
target_authorization: ?[]const u8,
) std.mem.Allocator.Error!BuildRequestResult {
) std.mem.Allocator.Error![]u8 {
const allocator = vm.allocator;
// Check for user overrides
@@ -1235,11 +1221,6 @@ fn buildRequestBody(
// Generate a new key if user key is invalid or not provided
break :blk std.base64.standard.Encoder.encode(&encoded_buf, &vm.rareData().nextUUID().bytes);
};
// Compute the expected Sec-WebSocket-Accept value per RFC 6455 §4.2.2:
// base64(SHA-1(Sec-WebSocket-Key + "258EAFA5-E914-47DA-95CA-C5AB0DC85B11"))
const expected_accept = computeAcceptValue(key);
const protocol = if (user_protocol) |p| p.slice() else client_protocol.slice();
const pathname_ = pathname.toSlice(allocator);
@@ -1292,26 +1273,7 @@ fn buildRequestBody(
// Build request with user overrides
if (user_host) |h| {
return .{
.body = try std.fmt.allocPrint(
allocator,
"GET {s} HTTP/1.1\r\n" ++
"Host: {f}\r\n" ++
"Connection: Upgrade\r\n" ++
"Upgrade: websocket\r\n" ++
"Sec-WebSocket-Version: 13\r\n" ++
"Sec-WebSocket-Extensions: permessage-deflate; client_max_window_bits\r\n" ++
"{f}" ++
"{s}" ++
"\r\n",
.{ pathname_.slice(), h, pico_headers, extra_headers_buf.items },
),
.expected_accept = expected_accept,
};
}
return .{
.body = try std.fmt.allocPrint(
return try std.fmt.allocPrint(
allocator,
"GET {s} HTTP/1.1\r\n" ++
"Host: {f}\r\n" ++
@@ -1322,24 +1284,23 @@ fn buildRequestBody(
"{f}" ++
"{s}" ++
"\r\n",
.{ pathname_.slice(), host_fmt, pico_headers, extra_headers_buf.items },
),
.expected_accept = expected_accept,
};
}
.{ pathname_.slice(), h, pico_headers, extra_headers_buf.items },
);
}
/// Compute the expected Sec-WebSocket-Accept value per RFC 6455 §4.2.2:
/// base64(SHA-1(key ++ "258EAFA5-E914-47DA-95CA-C5AB0DC85B11"))
fn computeAcceptValue(key: []const u8) [28]u8 {
const websocket_guid = "258EAFA5-E914-47DA-95CA-C5AB0DC85B11";
var hasher = bun.sha.Hashers.SHA1.init();
hasher.update(key);
hasher.update(websocket_guid);
var hash: bun.sha.Hashers.SHA1.Digest = undefined;
hasher.final(&hash);
var result: [28]u8 = undefined;
_ = bun.base64.encode(&result, &hash);
return result;
return try std.fmt.allocPrint(
allocator,
"GET {s} HTTP/1.1\r\n" ++
"Host: {f}\r\n" ++
"Connection: Upgrade\r\n" ++
"Upgrade: websocket\r\n" ++
"Sec-WebSocket-Version: 13\r\n" ++
"Sec-WebSocket-Extensions: permessage-deflate; client_max_window_bits\r\n" ++
"{f}" ++
"{s}" ++
"\r\n",
.{ pathname_.slice(), host_fmt, pico_headers, extra_headers_buf.items },
);
}
const log = Output.scoped(.WebSocketUpgradeClient, .visible);

View File

@@ -1,114 +0,0 @@
import { TCPSocketListener } from "bun";
import { describe, expect, test } from "bun:test";
import { WebSocket } from "ws";
describe("WebSocket Sec-WebSocket-Accept validation", () => {
test("rejects handshake with incorrect Sec-WebSocket-Accept", async () => {
let server: TCPSocketListener | undefined;
let client: WebSocket | undefined;
try {
server = Bun.listen({
socket: {
data(socket, data) {
const frame = data.toString("utf-8");
if (!frame.startsWith("GET")) return;
// Send back a 101 with an INCORRECT Sec-WebSocket-Accept value
socket.write(
"HTTP/1.1 101 Switching Protocols\r\n" +
"Upgrade: websocket\r\n" +
"Connection: Upgrade\r\n" +
"Sec-WebSocket-Accept: dGhlIHNhbXBsZSBub25jZQ==\r\n" +
"\r\n",
);
socket.flush();
},
},
hostname: "127.0.0.1",
port: 0,
});
const { promise, resolve } = Promise.withResolvers<{ code: number; reason: string }>();
client = new WebSocket(`ws://127.0.0.1:${server.port}`);
client.addEventListener("error", () => {
// Expected: connection should fail
resolve({ code: -1, reason: "error" });
});
client.addEventListener("close", event => {
resolve({ code: event.code, reason: event.reason });
});
client.addEventListener("open", () => {
resolve({ code: 0, reason: "opened unexpectedly" });
});
const result = await promise;
// The connection should NOT have opened successfully
expect(result.code).not.toBe(0);
} finally {
client?.close();
server?.stop(true);
}
});
test("accepts handshake with correct Sec-WebSocket-Accept", async () => {
let server: TCPSocketListener | undefined;
let client: WebSocket | undefined;
try {
server = Bun.listen({
socket: {
data(socket, data) {
const frame = data.toString("utf-8");
if (!frame.startsWith("GET")) return;
const keyMatch = /Sec-WebSocket-Key: (.*)\r\n/.exec(frame);
if (!keyMatch) return;
// Compute the CORRECT accept value per RFC 6455
const hasher = new Bun.CryptoHasher("sha1");
hasher.update(keyMatch[1]);
hasher.update("258EAFA5-E914-47DA-95CA-C5AB0DC85B11");
const accept = hasher.digest("base64");
socket.write(
"HTTP/1.1 101 Switching Protocols\r\n" +
"Upgrade: websocket\r\n" +
"Connection: Upgrade\r\n" +
`Sec-WebSocket-Accept: ${accept}\r\n` +
"\r\n",
);
socket.flush();
// Send a text frame with "hello" to confirm the connection works
const payload = Buffer.from("hello");
const wsFrame = Buffer.alloc(2 + payload.length);
wsFrame[0] = 0x81; // FIN + text opcode
wsFrame[1] = payload.length;
payload.copy(wsFrame, 2);
socket.write(wsFrame);
socket.flush();
},
},
hostname: "127.0.0.1",
port: 0,
});
const { promise, resolve, reject } = Promise.withResolvers<string>();
client = new WebSocket(`ws://127.0.0.1:${server.port}`);
client.addEventListener("error", err => {
reject(new Error(err.message));
});
client.addEventListener("message", event => {
resolve(event.data.toString("utf-8"));
});
expect(await promise).toBe("hello");
} finally {
client?.close();
server?.stop(true);
}
});
});

View File

@@ -0,0 +1,41 @@
import { expect, test } from "bun:test";
import { bunEnv, bunExe } from "harness";
// Regression test for https://github.com/oven-sh/bun/issues/27490
// bmalloc SYSCALL macro was spinning at 100% CPU on madvise EAGAIN
// due to zero-delay tight loop with no backoff or retry cap.
//
// This test verifies that heavy allocation workloads complete without
// hanging. The original bug caused GC threads to spin indefinitely
// on madvise(MADV_DONTDUMP) returning EAGAIN under mmap_write_lock
// contention, freezing the process.
test("heavy allocation workload completes without hanging", async () => {
await using proc = Bun.spawn({
cmd: [
bunExe(),
"-e",
`
// Simulate allocation-heavy workload that triggers GC pressure
const arrays = [];
for (let i = 0; i < 100; i++) {
// Allocate and release large buffers to trigger GC decommit cycles
for (let j = 0; j < 100; j++) {
arrays.push(new ArrayBuffer(1024 * 64));
}
// Force some to be collected
arrays.length = 0;
Bun.gc(true);
}
console.log("OK");
`,
],
env: bunEnv,
stdout: "pipe",
stderr: "pipe",
});
const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]);
expect(stdout.trim()).toBe("OK");
expect(exitCode).toBe(0);
}, 30_000);