Skip to content

Commit a5712b9

Browse files
authored
Fix 100% CPU usage with idle WebSocket connections on macOS (kqueue) (#25475)
### What does this PR do? Fixes a bug where idle WebSocket connections would cause 100% CPU usage on macOS and other BSD systems using kqueue. **Root cause:** The kqueue event filter comparison was using bitwise AND (`&`) instead of equality (`==`) when checking the filter type. Combined with missing `EV_ONESHOT` flags on writable events, this caused the event loop to continuously spin even when no actual I/O was pending. **Changes:** 1. **Fixed filter comparison** in `epoll_kqueue.c`: Changed `filter & EVFILT_READ` to `filter == EVFILT_READ` (same for `EVFILT_WRITE`). The filter field is a value, not a bitmask. 2. **Added `EV_ONESHOT` flag** to writable events: kqueue writable events now use one-shot mode to prevent continuous triggering. 3. **Re-arm writable events when needed**: After a one-shot writable event fires, the code now properly updates the poll state and re-arms the writable event if another write is still pending. ### How did you verify your code works? Added a test that: 1. Creates a TLS WebSocket server and client 2. Sends messages then lets the connection sit idle 3. Measures CPU usage over 3 seconds 4. Fails if CPU usage exceeds 2% (expected is ~0.XX% when idle)
1 parent 7dcd49f commit a5712b9

File tree

4 files changed

+84
-6
lines changed

4 files changed

+84
-6
lines changed

packages/bun-usockets/src/eventing/epoll_kqueue.c

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -228,8 +228,8 @@ void us_loop_run(struct us_loop_t *loop) {
228228
// > Instead, the filter will aggregate the events into a single kevent struct
229229
// Note: EV_ERROR only sets the error in data as part of changelist. Not in this call!
230230
int events = 0
231-
| ((filter & EVFILT_READ) ? LIBUS_SOCKET_READABLE : 0)
232-
| ((filter & EVFILT_WRITE) ? LIBUS_SOCKET_WRITABLE : 0);
231+
| ((filter == EVFILT_READ) ? LIBUS_SOCKET_READABLE : 0)
232+
| ((filter == EVFILT_WRITE) ? LIBUS_SOCKET_WRITABLE : 0);
233233
const int error = (flags & (EV_ERROR)) ? ((int)fflags || 1) : 0;
234234
const int eof = (flags & (EV_EOF));
235235
#endif
@@ -360,11 +360,11 @@ int kqueue_change(int kqfd, int fd, int old_events, int new_events, void *user_d
360360
if(!is_readable && !is_writable) {
361361
if(!(old_events & LIBUS_SOCKET_WRITABLE)) {
362362
// if we are not reading or writing, we need to add writable to receive FIN
363-
EV_SET64(&change_list[change_length++], fd, EVFILT_WRITE, EV_ADD, 0, 0, (uint64_t)(void*)user_data, 0, 0);
363+
EV_SET64(&change_list[change_length++], fd, EVFILT_WRITE, EV_ADD | EV_ONESHOT, 0, 0, (uint64_t)(void*)user_data, 0, 0);
364364
}
365365
} else if ((new_events & LIBUS_SOCKET_WRITABLE) != (old_events & LIBUS_SOCKET_WRITABLE)) {
366366
/* Do they differ in writable? */
367-
EV_SET64(&change_list[change_length++], fd, EVFILT_WRITE, (new_events & LIBUS_SOCKET_WRITABLE) ? EV_ADD : EV_DELETE, 0, 0, (uint64_t)(void*)user_data, 0, 0);
367+
EV_SET64(&change_list[change_length++], fd, EVFILT_WRITE, (new_events & LIBUS_SOCKET_WRITABLE) ? EV_ADD | EV_ONESHOT : EV_DELETE, 0, 0, (uint64_t)(void*)user_data, 0, 0);
368368
}
369369
int ret;
370370
do {

packages/bun-usockets/src/loop.c

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -375,7 +375,11 @@ void us_internal_dispatch_ready_poll(struct us_poll_t *p, int error, int eof, in
375375
/* Note: if we failed a write as a socket of one loop then adopted
376376
* to another loop, this will be wrong. Absurd case though */
377377
loop->data.last_write_failed = 0;
378-
378+
#ifdef LIBUS_USE_KQUEUE
379+
/* Kqueue is one-shot so is not writable anymore */
380+
p->state.poll_type = us_internal_poll_type(p) | ((events & LIBUS_SOCKET_READABLE) ? POLL_TYPE_POLLING_IN : 0);
381+
#endif
382+
379383
s = s->context->on_writable(s);
380384

381385
if (!s || us_socket_is_closed(0, s)) {
@@ -385,6 +389,11 @@ void us_internal_dispatch_ready_poll(struct us_poll_t *p, int error, int eof, in
385389
/* If we have no failed write or if we shut down, then stop polling for more writable */
386390
if (!loop->data.last_write_failed || us_socket_is_shut_down(0, s)) {
387391
us_poll_change(&s->p, loop, us_poll_events(&s->p) & LIBUS_SOCKET_READABLE);
392+
} else {
393+
#ifdef LIBUS_USE_KQUEUE
394+
/* Kqueue one-shot writable needs to be re-enabled */
395+
us_poll_change(&s->p, loop, us_poll_events(&s->p) | LIBUS_SOCKET_WRITABLE);
396+
#endif
388397
}
389398
}
390399

test/js/bun/http/bun-server.test.ts

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,13 @@
11
import type { Server, ServerWebSocket, Socket } from "bun";
22
import { describe, expect, test } from "bun:test";
3-
import { bunEnv, bunExe, rejectUnauthorizedScope, tempDirWithFiles, tls } from "harness";
3+
import { bunEnv, bunExe, bunRun, rejectUnauthorizedScope, tempDirWithFiles, tls } from "harness";
44
import path from "path";
55

66
describe.concurrent("Server", () => {
7+
test("should not use 100% CPU when websocket is idle", async () => {
8+
const { stderr } = bunRun(path.join(import.meta.dir, "bun-websocket-cpu-fixture.js"));
9+
expect(stderr).toBe("");
10+
});
711
test("normlizes incoming request URLs", async () => {
812
using server = Bun.serve({
913
fetch(request) {

test/js/bun/http/bun-websocket-cpu-fixture.js

Lines changed: 65 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

0 commit comments

Comments
 (0)