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
9 changes: 9 additions & 0 deletions packages/bun-usockets/src/eventing/epoll_kqueue.c
Original file line number Diff line number Diff line change
Expand Up @@ -355,6 +355,15 @@ void us_loop_run_bun_tick(struct us_loop_t *loop, const struct timespec* timeout

loop->data.tick_depth++;

#ifdef BUN_DEBUG
/* A tick starting while an outer ready-poll dispatch is still mid-batch
* means a poll callback synchronously waited on the event loop. Unlike
* tick_depth (any re-entry), this counts only mid-dispatch re-entry. */
if (loop->current_ready_poll < loop->num_ready_polls) {
loop->data.nested_dispatch_ticks++;
}
#endif

struct us_internal_callback_t *timer_callback = (struct us_internal_callback_t*)loop->data.sweep_timer;

// Only integrate the loop if we haven't already.
Expand Down
5 changes: 5 additions & 0 deletions packages/bun-usockets/src/internal/loop_data.h
Original file line number Diff line number Diff line change
Expand Up @@ -80,6 +80,11 @@ struct us_internal_loop_data_t {
* sockets must be deferred to the outermost tick so the outer dispatch
* doesn't read a freed poll. */
int tick_depth;
/* Ticks that began while an outer ready-poll dispatch was still mid-batch,
* i.e. a poll callback synchronously waited on the event loop. Only
* incremented under BUN_DEBUG; always 0 in release (oven-sh/bun#33261).
* Fills what was already this struct's tail padding, so it costs no space. */
int nested_dispatch_ticks;
};

#endif // LOOP_DATA_H
6 changes: 6 additions & 0 deletions packages/bun-usockets/src/loop.c
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,7 @@

#if ASSERT_ENABLED
extern const size_t Bun__lock__size;
extern const size_t Bun__internal_loop_data__size;
#endif

extern void Bun__internal_ensureDateHeaderTimerIsEnabled(struct us_loop_t *loop);
Expand Down Expand Up @@ -81,6 +82,11 @@ void us_internal_loop_data_init(struct us_loop_t *loop, void (*wakeup_cb)(struct
if (Bun__lock__size != sizeof(loop->data.mutex)) {
BUN_PANIC("The size of the mutex must match the size of the lock");
}
/* The Rust mirror (src/uws_sys/InternalLoopData.rs) must stay
* layout-identical to this struct (size-only check). */
if (Bun__internal_loop_data__size != sizeof(loop->data)) {
BUN_PANIC("us_internal_loop_data_t layout differs between C and Rust");
}
Comment thread
claude[bot] marked this conversation as resolved.
#endif
}

Expand Down
11 changes: 9 additions & 2 deletions src/js/internal-for-testing.ts
Original file line number Diff line number Diff line change
Expand Up @@ -359,8 +359,15 @@ export const isMemoryPressureWatcherInstalled: () => boolean = $newCppFunction(
0,
);

export const getEventLoopStats: () => { activeTasks: number; concurrentRef: number; numPolls: number } =
$newRustFunction("event_loop.rs", "getActiveTasks", 0);
export const getEventLoopStats: () => {
activeTasks: number;
concurrentRef: number;
numPolls: number;
/** Ticks entered while an outer poll dispatch was still mid-batch, i.e. a
* callback synchronously waited on the event loop. Debug builds only, POSIX
* only; always 0 in release builds and on Windows. */
nestedDispatchTicks: number;
} = $newRustFunction("event_loop.rs", "getActiveTasks", 0);

export const hostedGitInfo = {
parseUrl: $newRustFunction("hosted_git_info.rs", "TestingAPIs.jsParseUrl", 1),
Expand Down
11 changes: 10 additions & 1 deletion src/jsc/event_loop.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1150,7 +1150,7 @@ pub fn get_active_tasks(global_object: &JSGlobalObject, _frame: &CallFrame) -> J
// fields and call &-methods on it for the duration of this host fn.
let vm_ref = global_object.bun_vm();
let event_loop = vm_ref.event_loop_shared();
let result = JSValue::create_empty_object(global_object, 3);
let result = JSValue::create_empty_object(global_object, 4);
result.put(
global_object,
b"activeTasks",
Expand All @@ -1174,6 +1174,15 @@ pub fn get_active_tasks(global_object: &JSGlobalObject, _frame: &CallFrame) -> J
b"numPolls",
JSValue::js_number(num_polls as f64),
);
// Only the debug-build POSIX tick increments this; 0 everywhere else.
// SAFETY: uws::Loop::get() returns a live process-global loop.
let nested_dispatch_ticks =
unsafe { (*uws::Loop::get()).internal_loop_data.nested_dispatch_ticks };
result.put(
global_object,
b"nestedDispatchTicks",
JSValue::js_number(nested_dispatch_ticks as f64),
);
Ok(result)
}

Expand Down
9 changes: 9 additions & 0 deletions src/uws_sys/InternalLoopData.rs
Original file line number Diff line number Diff line change
Expand Up @@ -55,8 +55,17 @@ pub struct InternalLoopData {
// Higher tier (`bun_runtime`) casts this back when reading.
pub jsc_vm: *const c_void,
pub tick_depth: c_int,
/// See `nested_dispatch_ticks` in `loop_data.h` (oven-sh/bun#33261).
/// Only the debug (`BUN_DEBUG`) POSIX tick increments it.
pub nested_dispatch_ticks: c_int,
}

/// Checked against `sizeof(struct us_internal_loop_data_t)` in
/// `us_internal_loop_data_init` (loop.c) so C/Rust drift in this mirror panics
/// at startup. Size-only: it cannot see drift that hides in padding.
#[unsafe(no_mangle)]
pub(crate) static Bun__internal_loop_data__size: usize = core::mem::size_of::<InternalLoopData>();

impl InternalLoopData {
const LIBUS_RECV_BUFFER_LENGTH: usize = 524288;

Expand Down
51 changes: 51 additions & 0 deletions test/js/bun/util/event-loop-stats.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
import { getEventLoopStats } from "bun:internal-for-testing";
import { expect, test } from "bun:test";
import { isDebug, isWindows } from "harness";

// `nestedDispatchTicks` counts event-loop ticks that began while an outer
// ready-poll dispatch was still mid-batch, i.e. a poll callback synchronously
// waited on the event loop. https://github.com/oven-sh/bun/issues/33261
// The counter only exists in debug builds (BUN_DEBUG), and only the POSIX
// event loop increments it: release builds and Windows always report 0.
test.skipIf(isWindows || !isDebug)(
"nestedDispatchTicks counts re-entrant ticks started inside a dispatch callback",
async () => {
const before = getEventLoopStats().nestedDispatchTicks;
expect(before).toBeNumber();

// The fetch handler runs inside the server socket's poll dispatch, and
// HTMLRewriter.transform with an async handler synchronously waits on that
// handler's promise (waitForPromise), so a nested tick starts while the
// dispatch is still mid-batch. Sockets are level-triggered, so the nested
// tick cannot lose any one-shot event. When #33261 de-blocks HTMLRewriter,
// this trigger must become another synchronous waiter (or an internal hook).
const rewriter = new HTMLRewriter().on("p", {
async element() {
await Bun.sleep(1);
},
});
using server = Bun.serve({
port: 0,
fetch() {
rewriter.transform("<p>x</p>");
return new Response("ok");
},
});
const res = await fetch(`http://localhost:${server.port}/`);
expect(await res.text()).toBe("ok");

expect(getEventLoopStats().nestedDispatchTicks).toBeGreaterThan(before);
},
);

// Runs in every build flavor: the field always exists, and outside debug
// builds it must stay 0 (the counter is compiled out).
test("getEventLoopStats() always reports nestedDispatchTicks", async () => {
const stats = getEventLoopStats();
expect(stats.nestedDispatchTicks).toBeNumber();
expect(stats.nestedDispatchTicks).toBeGreaterThanOrEqual(0);
if (!isDebug) {
await Promise.all(Array.from({ length: 4 }, (_, i) => Bun.$`printf %s r${i}`.quiet()));
expect(getEventLoopStats().nestedDispatchTicks).toBe(0);
}
});
Loading