diff --git a/packages/bun-usockets/src/eventing/epoll_kqueue.c b/packages/bun-usockets/src/eventing/epoll_kqueue.c index 7e347a378d5b..344e2dcf094f 100644 --- a/packages/bun-usockets/src/eventing/epoll_kqueue.c +++ b/packages/bun-usockets/src/eventing/epoll_kqueue.c @@ -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. diff --git a/packages/bun-usockets/src/internal/loop_data.h b/packages/bun-usockets/src/internal/loop_data.h index 4294a6636be4..7c4b7b89bf06 100644 --- a/packages/bun-usockets/src/internal/loop_data.h +++ b/packages/bun-usockets/src/internal/loop_data.h @@ -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 diff --git a/packages/bun-usockets/src/loop.c b/packages/bun-usockets/src/loop.c index c602732bb98a..7f2dc96b184d 100644 --- a/packages/bun-usockets/src/loop.c +++ b/packages/bun-usockets/src/loop.c @@ -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); @@ -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"); + } #endif } diff --git a/src/js/internal-for-testing.ts b/src/js/internal-for-testing.ts index e0d3d9252a85..d5178dbb9669 100644 --- a/src/js/internal-for-testing.ts +++ b/src/js/internal-for-testing.ts @@ -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), diff --git a/src/jsc/event_loop.rs b/src/jsc/event_loop.rs index 9e7cdf41a2ed..89ebbdf55b46 100644 --- a/src/jsc/event_loop.rs +++ b/src/jsc/event_loop.rs @@ -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", @@ -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) } diff --git a/src/uws_sys/InternalLoopData.rs b/src/uws_sys/InternalLoopData.rs index 74df8437d991..95efcc8f9602 100644 --- a/src/uws_sys/InternalLoopData.rs +++ b/src/uws_sys/InternalLoopData.rs @@ -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::(); + impl InternalLoopData { const LIBUS_RECV_BUFFER_LENGTH: usize = 524288; diff --git a/test/js/bun/util/event-loop-stats.test.ts b/test/js/bun/util/event-loop-stats.test.ts new file mode 100644 index 000000000000..5b0429047fdd --- /dev/null +++ b/test/js/bun/util/event-loop-stats.test.ts @@ -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("

x

"); + 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); + } +});