Skip to content

event loop: count ticks that re-enter a mid-batch poll dispatch - #33267

Open
alii wants to merge 6 commits into
mainfrom
claude/nested-dispatch-tick-counter
Open

alii wants to merge 6 commits into
mainfrom
claude/nested-dispatch-tick-counter

Conversation

@alii

@alii alii commented Jul 2, 2026

Copy link
Copy Markdown
Member

What does this PR do?

Adds a nested_dispatch_ticks counter to the POSIX event loop: it increments when us_loop_run_bun_tick begins while an outer ready-poll dispatch is still mid-batch — i.e. a poll callback (or a continuation it drained inline) synchronously waited on the event loop through one of the waitForPromise callers. The counter is exposed as getEventLoopStats().nestedDispatchTicks in bun:internal-for-testing (always 0 on Windows, whose readiness is driven by libuv).

First step of #33261: before synchronous event-loop re-entry from inside dispatch callbacks can be forbidden (and expect().resolves / HTMLRewriter.transform de-blocked), the violation has to be observable. This counter is that detector — the follow-up work uses it to prove "this API no longer re-enters the loop", and it is the condition a future debug assertion will fire on.

No behavior change: one well-predicted integer compare + increment at tick entry, no allocation. nested_dispatch_ticks is deliberately narrower than tick_depth (which also counts re-entry from the pre/post phases): it counts only re-entry that starts while a ready-poll batch is mid-dispatch, which is exactly the state that loses one-shot events.

How did you verify your code works?

test/js/bun/util/event-loop-stats.test.ts: an HTMLRewriter.transform() with an async element handler, called from inside a Bun.serve fetch handler, synchronously waits on the event loop from inside the server socket's poll dispatch — the counter increments. Server sockets are level-triggered, so unlike subprocess-based triggers this cannot lose one-shot events and does not depend on pidfd/waiter-thread differences across platforms. The test fails without the native change (USE_SYSTEM_BUN=1).

Also verified outside the test runner with a plain script through the CLI: a fresh process reports 0, concurrent shell commands leave it at 0, a normal async server handler leaves it at 0, and a synchronously-waiting handler moves it to 1.

Adds a nested_dispatch_ticks counter to the POSIX event loop, incremented
when us_loop_run_bun_tick begins while an outer ready-poll dispatch is
still mid-batch - i.e. a poll callback synchronously waited on the event
loop. Exposed as getEventLoopStats().nestedDispatchTicks in
bun:internal-for-testing (always 0 on Windows, whose readiness is driven
by libuv).

This makes synchronous event-loop re-entry from inside a dispatch callback
observable so it can be asserted on, and eventually forbidden (#33261).
No behavior change: one well-predicted integer compare and increment at
tick entry.
@robobun

robobun commented Jul 2, 2026 •

Copy link
Copy Markdown
Collaborator
Updated 12:58 PM PT - Jul 2nd, 2026

❌ @alii, your commit ef3022f has 1 failures in Build #68041 (All Failures):


🧪   To try this PR locally:

bunx bun-pr 33267

That installs a local version of the PR into your bun-33267 executable, so you can run:

bun-33267 --bun

@github-actions

github-actions Bot commented Jul 2, 2026

Copy link
Copy Markdown
Contributor

Found 1 issue this PR may fix:

  1. bun test waits at 100% cpu usage for a promise that will resolve after expect() #14950 - bun test hangs at 100% CPU on un-awaited expect().resolves, caused by the same waitForPromise synchronous re-entry pattern this counter is designed to detect

If this is helpful, copy the block below into the PR description to auto-close these issues on merge.

Fixes #14950

🤖 Generated with Claude Code

@coderabbitai

coderabbitai Bot commented Jul 2, 2026 •

Copy link
Copy Markdown
Contributor

Review Change Stack

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: ASSERTIVE

Plan: Pro

Run ID: eb0551e0-4639-4f6b-92f5-7b9762123b02

📥 Commits

Reviewing files that changed from the base of the PR and between 2855d5b and ef3022f.

📒 Files selected for processing (1)
  • packages/bun-usockets/src/internal/loop_data.h

Walkthrough

This PR adds a nestedDispatchTicks event-loop stat, threads it through the C, Rust, and JS layers, and adds tests for debug and non-debug behavior.

Changes

Nested Dispatch Tick Tracking

Layer / File(s) Summary
uSockets loop data field and increment logic
packages/bun-usockets/src/internal/loop_data.h, packages/bun-usockets/src/eventing/epoll_kqueue.c
Adds nested_dispatch_ticks to us_internal_loop_data_t and increments it in us_loop_run_bun_tick when entry happens during an active ready-poll batch.
Rust FFI struct and JS binding exposure
src/uws_sys/InternalLoopData.rs, packages/bun-usockets/src/loop.c, src/jsc/event_loop.rs, src/js/internal-for-testing.ts
Adds the Rust mirror field, checks the C and Rust layouts at startup, reads the counter in get_active_tasks, exposes nestedDispatchTicks to JS, and updates the getEventLoopStats return type.
Verification test
test/js/bun/util/event-loop-stats.test.ts
Adds Bun tests that assert the counter increases during nested dispatch and remains available as a non-negative stat in all builds.

Possibly related issues

🚥 Pre-merge checks | ✅ 4
✅ Passed checks (4 passed)
Check name Status Explanation
Title check ✅ Passed The title clearly and concisely summarizes the main change: counting nested event-loop ticks during mid-batch poll dispatch re-entry.
Description check ✅ Passed The description matches the template and includes both required sections with substantial verification details.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.

Comment @coderabbitai help to get the list of available commands.

@claude claude Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I didn't find any issues, but this touches the core event-loop tick path and the C↔Rust InternalLoopData FFI struct layout, so a quick human sanity-check on the field placement and predicate would be worthwhile.

Extended reasoning...

Overview

Adds a diagnostic nested_dispatch_ticks counter to us_internal_loop_data_t, incremented at the top of us_loop_run_bun_tick when current_ready_poll < num_ready_polls (i.e. re-entered mid-dispatch). The field is appended to the end of the C struct (loop_data.h) and its Rust mirror (InternalLoopData.rs), read in get_active_tasks (event_loop.rs), and surfaced as getEventLoopStats().nestedDispatchTicks in bun:internal-for-testing. A new test drives it via HTMLRewriter inside a Bun.serve handler.

Security risks

None identified. The counter is a passive read-only stat exposed through an internal testing module; no user-controlled input reaches it and it doesn't gate any behavior.

Level of scrutiny

Medium. The logic is trivial (one compare + increment, calloc-zeroed on all platforms including the libuv path), and exposure is testing-only. However, the increment sits in us_loop_run_bun_tick — the hottest path in the runtime — and the change modifies an FFI struct whose layout must stay byte-identical between C and Rust. The field is correctly appended after tick_depth (both int/c_int, no padding change), and the compile-time offset_of!(PosixLoop, num_polls) == size_of::<InternalLoopData>() assertion in Loop.rs will still hold, but layout changes to this struct historically warrant a maintainer glance.

Other factors

The predicate placement (before us_internal_loop_pre, reading the outer current_ready_poll/num_ready_polls before the nested tick overwrites them) and the deliberate distinction from tick_depth are design choices tied to #33261 that a maintainer should confirm match the intended follow-up assertion semantics. No prior human reviews or outstanding comments; CI is still building.

The nested_dispatch_ticks field and its increment now exist only under
BUN_DEBUG, mirrored in Rust with cfg(bun_debug) (both come from the Debug
build type). Because this is the first build-type-conditional field in a
C/Rust-mirrored struct, us_internal_loop_data_init now asserts (under
ASSERT_ENABLED) that sizeof(us_internal_loop_data_t) matches the Rust
mirror's size, following the existing Bun__lock__size check, so a future
skew panics at startup instead of corrupting the loop struct.

getEventLoopStats().nestedDispatchTicks still always exists in JS and
reports 0 outside debug builds; a second, ungated test covers that shape
on every build flavor.
@alii

alii commented Jul 2, 2026

Copy link
Copy Markdown
Member Author

Pushed a557b71: the counter is now debug-build-only, per review feedback.

  • The nested_dispatch_ticks field + increment exist only under BUN_DEBUG, mirrored in Rust with #[cfg(bun_debug)] — the two are set by the same Debug build-type switch (scripts/build/config.ts → flags.ts / rust.ts), and deliberately not debug_assertions, which release-assert builds also enable.
  • Because this is the first build-type-conditional field in a C↔Rust mirrored struct, us_internal_loop_data_init now asserts (under ASSERT_ENABLED) that sizeof(struct us_internal_loop_data_t) matches the Rust mirror's size — same pattern as the existing Bun__lock__size check — so any future skew is a startup panic, not silent corruption.
  • getEventLoopStats().nestedDispatchTicks still always exists in JS (0 outside debug builds); a second, ungated test keeps the plumbing covered on release/asan lanes, and the debug-only behavior test is gated on isDebug.

An equally valid, slightly smaller shape is to keep the field unconditional and gate only the increment (no conditional layout at all) — happy to switch if a reviewer prefers that; the observable behavior is identical.

Comment thread packages/bun-usockets/src/loop.c
alii added 2 commits July 2, 2026 11:50
nested_dispatch_ticks occupies what was tail padding after tick_depth, so
its presence never changes sizeof(us_internal_loop_data_t) - which also
means the size assertion could not have detected a BUN_DEBUG/bun_debug
mismatch for this field. Drop the conditional layout entirely: the field
always exists (it costs no space), only the increment compiles under
BUN_DEBUG, and the mirror size check keeps its honest, general purpose.

@claude claude Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks for taking the suggestion — the unconditional field + debug-gated increment looks right, and the reworded size-check comment is accurate now. LGTM.

Extended reasoning...

Overview

Adds a debug-only nested_dispatch_ticks diagnostic counter to the POSIX event loop and exposes it via bun:internal-for-testing's getEventLoopStats(). Touches packages/bun-usockets/src/{eventing/epoll_kqueue.c, internal/loop_data.h, loop.c}, the Rust mirror src/uws_sys/InternalLoopData.rs, the host-fn in src/jsc/event_loop.rs, the internal-for-testing TS surface, and a new test file. This is groundwork for #33261 — a detector for synchronous event-loop re-entry from inside a poll dispatch, with no user-facing behavior change.

Prior review

I previously flagged (inline on loop.c:89, now resolved) that the C↔Rust size assertion couldn't detect the BUN_DEBUG/cfg(bun_debug) skew it claimed to guard because the new int fills exactly the 4 bytes of tail padding after tick_depth. The author took the suggested fix in 2855d5b/ef3022f: the field is now unconditional in both the C struct and the Rust #[repr(C)] mirror, only the increment is #ifdef BUN_DEBUG, and the size-check comment now honestly says "size-only: it cannot see drift that hides in padding." That fully addresses my concern — there is no longer any conditional layout to skew.

Security risks

None. The counter is a debug-build integer increment on a calloc-zeroed field, exposed only through bun:internal-for-testing (which is gated to debug builds and Bun's own CI). No user input is parsed, no allocation, no new syscall, no auth/crypto/permissions surface.

Level of scrutiny

Event-loop code is normally high-scrutiny, but the actual release-build delta here is essentially nil: the new int occupies what was already tail padding (struct sizeof unchanged, no field offsets shift), the increment is compiled out, and the only new release-path code is a startup sizeof equality check under ASSERT_ENABLED following the established Bun__lock__size pattern. In debug builds it's one well-predicted compare + increment at tick entry. There is no control-flow, dispatch, or lifetime change to the poll loop itself. Given that, and that my earlier feedback was addressed exactly as suggested, I'm comfortable approving.

Other factors

  • Verified WindowsLoop also carries internal_loop_data: InternalLoopData (src/uws_sys/Loop.rs:392), so the unconditional Rust read in get_active_tasks compiles on Windows and reads the calloc-zeroed 0 (Windows never runs epoll_kqueue.c).
  • The #[unsafe(no_mangle)] pub(crate) static Bun__internal_loop_data__size follows the exact precedent of Bun__lock__size in src/threading/Mutex.rs:411-412, so the C extern const size_t will link.
  • Test coverage: a debug-gated test proves the counter increments via HTMLRewriter.transform inside a Bun.serve handler (level-triggered socket, so no one-shot-loss risk), and an ungated test proves the field is present and stays 0 outside debug builds. Uses port: 0, using server, and awaits observable conditions.
  • The bug-hunting system found no issues; my resolved thread was the only outstanding feedback.

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants