Skip to content
Closed
Show file tree
Hide file tree
Changes from 3 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
275 changes: 134 additions & 141 deletions packages/bun-usockets/src/eventing/epoll_kqueue.c

Large diffs are not rendered by default.

5 changes: 2 additions & 3 deletions packages/bun-usockets/src/internal/eventing/epoll_kqueue.h
Original file line number Diff line number Diff line change
Expand Up @@ -82,10 +82,9 @@ struct us_loop_t {
/* Number of non-fallthrough polls in the loop */
int num_polls;

/* Number of ready polls this iteration */
/* ready_polls[current_ready_poll .. num_ready_polls) are collected but not
* dispatched yet; the cursor is advanced before an entry is dispatched */
int num_ready_polls;

/* Current index in list of ready polls */
int current_ready_poll;

/* Loop's own file descriptor */
Expand Down
29 changes: 15 additions & 14 deletions src/io/posix_event_loop.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1456,9 +1456,13 @@ impl Pollable {
}
}

// `current_ready_poll`/`ready_polls` only exist on the POSIX uws loop layout;
// on Windows the libuv loop drives readiness, so this entry point is never
// linked there. Restrict to the platforms where the fields are present.
// Called by the epoll/kqueue loop's dispatch; on Windows the libuv loop drives
// readiness and this entry point is never linked.
#[cfg(any(target_os = "linux", target_os = "android"))]
type ReadyEvent = bun_sys::linux::epoll_event;
#[cfg(any(target_os = "macos", target_os = "freebsd"))]
type ReadyEvent = KQueueEvent;

#[cfg(any(
target_os = "linux",
target_os = "android",
Expand All @@ -1467,11 +1471,12 @@ impl Pollable {
))]
#[unsafe(no_mangle)]
/// # Safety
/// uWS C callback: `loop_` is the live per-thread `us_loop_t`; `tagged_pointer`
/// was registered via `Pollable::init` in `register_with_fd`.
/// uWS C callback: `tagged_pointer` was registered via `Pollable::init` in
/// `register_with_fd`; `event` is the loop's ready-poll entry being dispatched.
unsafe extern "C" fn Bun__internal_dispatch_ready_poll(
loop_: *mut Loop,
_loop: *mut Loop,
tagged_pointer: *mut c_void,
event: *const ReadyEvent,
) {
let tag = Pollable::from(tagged_pointer);

Expand All @@ -1485,14 +1490,10 @@ unsafe extern "C" fn Bun__internal_dispatch_ready_poll(
return;
}

// SAFETY: `loop_` is the live uws loop. Do *not* materialize `&mut *loop_`
// here — `on_update` (via `__bun_run_file_poll`) re-enters the loop and conjures
// a fresh `&mut Loop` through `EventLoopCtx::platform_event_loop()`; a
// protected `&mut Loop` spanning that call would be SB-UB. Take a short-lived
// `&*loop_` only to copy the POD event onto the stack (the `BackRef`-style
// accessor returns by value), then drop the borrow before dispatching so the
// handler is free to form its own `&mut Loop`.
let ev = unsafe { &*loop_ }.current_ready_event();
// Copied out: the handler may tick the loop, which reuses the array `event`
// points into.
// SAFETY: points at a live `ready_polls` entry for the duration of this call.
let ev = unsafe { *event };

#[cfg(any(target_os = "macos", target_os = "freebsd"))]
file_poll.on_kqueue_event(&ev);
Expand Down
16 changes: 2 additions & 14 deletions src/spawn/process.rs
Original file line number Diff line number Diff line change
Expand Up @@ -69,20 +69,8 @@ pub use bun_spawn_sys::{
/// tracked process exits and stays readable until the fd is closed, so a
/// plain level-triggered watch is sufficient: when the event fires we
/// `wait4(WNOHANG)`, and on success we close the pidfd (which removes it
/// from epoll).
///
/// `EPOLLONESHOT` is actively harmful here: the kernel disarms the fd the
/// instant `epoll_wait` returns it — before user-space has dispatched it.
/// If a poll callback then re-enters `us_loop_run_bun_tick` (e.g.
/// `expect(p).resolves` → `waitForPromise` → `autoTick`, or any other
/// `waitForPromise` path), the inner tick overwrites the shared
/// `loop->ready_polls`/`num_ready_polls`/`current_ready_poll` and the outer
/// dispatch silently skips its remaining events. A dropped one-shot pidfd
/// event is unrecoverable: the fd is disarmed with no re-arm path, so the
/// process's `'exit'` arrives only when the next unrelated timer wakes the
/// loop. Level-triggered makes a dropped slot harmless — the next
/// `epoll_wait` just returns it again. `rewatch_posix` still re-registers
/// defensively if `wait4` returns 0, which is a harmless `CTL_MOD`.
/// from epoll). `rewatch_posix` re-registers if `wait4` returns 0, which is a
/// harmless `CTL_MOD`.
///
/// macOS/FreeBSD watch the pid via `EVFILT_PROC` + `NOTE_EXIT`, which is
/// inherently once-per-process — keep `EV_ONESHOT` there so the kernel
Expand Down
19 changes: 2 additions & 17 deletions src/uws_sys/Loop.rs
Original file line number Diff line number Diff line change
Expand Up @@ -29,10 +29,9 @@ pub struct PosixLoop {
/// Number of non-fallthrough polls in the loop
pub num_polls: i32,

/// Number of ready polls this iteration
/// `ready_polls[current_ready_poll..num_ready_polls]` are collected but not
/// dispatched yet; the cursor is advanced before an entry is dispatched.
pub num_ready_polls: i32,

/// Current index in list of ready polls
pub(crate) current_ready_poll: i32,

/// Loop's own file descriptor
Expand Down Expand Up @@ -123,20 +122,6 @@ impl PosixLoop {
self.internal_loop_data.iteration_nr
}

/// Copy out the ready-poll event at `current_ready_poll`.
///
/// Safe back-reference accessor consolidating the C-dispatch
/// `(*loop_).ready_polls[(*loop_).current_ready_poll]` raw-deref pattern
/// into one short-lived `&self` borrow. `EventType` is POD (`epoll_event`
/// / `kevent64_s` / `kevent` — all `Copy` in `libc`), so the by-value
/// return is a stack copy the caller may borrow across re-entrant handler
/// dispatch without aliasing the loop.
#[inline]
pub fn current_ready_event(&self) -> EventType {
let idx = usize::try_from(self.current_ready_poll).expect("int cast");
self.ready_polls[idx]
}

pub fn inc(&mut self) {
bun_core::scoped_log!(Loop, "inc {} + 1 = {}", self.num_polls, self.num_polls + 1);
self.num_polls += 1;
Expand Down
213 changes: 213 additions & 0 deletions test/js/bun/net/nested-event-loop-fixture.ts

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

22 changes: 22 additions & 0 deletions test/js/bun/net/socket.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4662,3 +4662,25 @@ describe.concurrent("a socket closed by data() while its peer's reset is being d
expect(exitCode).toBe(0);
});
});

describe.concurrent("socket handlers that re-enter the event loop before returning", () => {
// The fixture runs under `bun test` so that expect(promise).resolves can drive
// nested event-loop ticks from inside a data callback. It covers a socket that
// is closed by its own data() and must stay allocated until that dispatch has
// returned, and an event the outer tick already collected that the nested tick
// has to deliver. Windows: #40023.
it.skipIf(isWindows)("keeps the socket alive and the collected events visible", async () => {
await using proc = Bun.spawn({
cmd: [bunExe(), "test", fileURLToPath(new URL("./nested-event-loop-fixture.ts", import.meta.url))],
env: bunEnv,
stdout: "pipe",
stderr: "pipe",
});
const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]);
// stdout carries only the runner's version banner; results go to stderr.
expect(stdout).toMatch(/^bun test v\S+ \(\S+\)\n$/);
expect(stderr).toContain(" 4 pass");
expect(proc.signalCode).toBeNull();
expect(exitCode).toBe(0);
});
});
Loading
Loading